From 31ba0f151abb140c6575061d966be1559fab47b1 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:12:58 +0000
Subject: [PATCH] chore: version packages
---
...organization-less-grant-readers-retired.md | 22 --
.changeset/24583-7a3-sharing-null-readers.md | 23 --
...83-7b3-organizationless-counter-retired.md | 29 --
.changeset/24583-7z-closing-d8-texts.md | 25 --
.../24583-tenant-column-not-null-ddl.md | 21 -
.../24690-stored-expression-dropped-fields.md | 18 -
.changeset/24692-add-record-retired.md | 43 ---
.changeset/24707-pg-multischema-introspect.md | 19 -
.changeset/24720-validation-rule-parent.md | 22 --
.changeset/24728-filterable-fields-residue.md | 43 ---
.../24750-invite-role-not-found-code.md | 13 -
.../24751-remind-notified-per-person.md | 11 -
.changeset/24757-fold-warning-retirement.md | 13 -
.changeset/24758-password-param-type.md | 15 -
.changeset/24760-action-state-visibility.md | 17 -
.../24762-impersonation-credential-posture.md | 12 -
.../24765-team-sessions-related-list.md | 17 -
.changeset/24766-auth-brand-binding.md | 18 -
.changeset/24766-branding-settings-v2.md | 37 --
.../24766-runtime-config-branding-settings.md | 17 -
.changeset/24766-setup-branding-entry.md | 11 -
.../24767-settings-test-send-recipient.md | 20 -
.../24772-primary-datasource-test-sync.md | 15 -
.changeset/24773-oauth-application-type.md | 13 -
.changeset/24773-oauth-form-types.md | 15 -
.../24773-oauth-register-refusal-status.md | 12 -
.changeset/24773-oauth-type-refusal.md | 21 -
.../24774-rotate-secret-cut-off-copy.md | 11 -
...-permission-set-assignment-person-title.md | 11 -
.changeset/24782-advisory-unevaluable-log.md | 13 -
.../24785-mention-recipients-read-gated.md | 11 -
.../24796-list-view-binding-explanations.md | 12 -
.../24799-vendor-client-address-trust.md | 17 -
...08-user-field-foreign-reference-warning.md | 15 -
.../24809-cors-doors-one-admission-rule.md | 23 --
.../24813-adopt-orphans-readonly-package.md | 18 -
.../24817-later-step-original-submitter.md | 12 -
.../24818-trustproxy-consumers-describe.md | 9 -
.../24821-operator-no-org-insert-403.md | 17 -
.changeset/24822-update-repoint-honoured.md | 35 --
.../24822-wall-judged-organization-stored.md | 36 --
.../24827-migrate-apply-birth-marker.md | 14 -
.../24833-dev-recover-line-names-no-origin.md | 11 -
.../24835-driver-update-leaves-caller-data.md | 11 -
.../24835-webhook-lost-delivery-error.md | 11 -
.../24844-google-env-credentials-save.md | 13 -
.../24863-approval-labels-reader-locale.md | 13 -
.../24865-auth-sms-template-organization.md | 14 -
.../24865-template-recipient-organization.md | 13 -
.changeset/24877-bridge-transport.md | 12 -
.changeset/24882-settings-format-refusal.md | 31 --
.../24896-prelude-grant-read-projection.md | 9 -
.changeset/24899-archived-mail-bodies.md | 19 -
.../24900-webhook-label-description-live.md | 9 -
.../24909-missing-driver-client-remedy.md | 13 -
.../24910-declared-webhook-undeliverable.md | 20 -
.../24915-approval-step-translation-key.md | 14 -
...3-sys-notification-subscription-retired.md | 25 --
.../24924-template-channel-email-sms.md | 14 -
.../24925-plan-names-ownership-ceremony.md | 15 -
.changeset/24936-create-user-dialog-copy.md | 9 -
.../24937-optional-driver-package-remedy.md | 13 -
.../24938-sys-user-security-tab-own-record.md | 11 -
.changeset/24939-bu-parent-delete-restrict.md | 16 -
.changeset/24944-export-lookup-title-field.md | 15 -
.changeset/24945-export-boolean-locale.md | 14 -
...948-organization-delete-refuse-nonempty.md | 33 --
.../24949-migrate-apply-confirmation-exit.md | 18 -
.../24950-sqlite-open-failure-no-stepdown.md | 14 -
.changeset/24951-duplicate-message-labels.md | 14 -
.../24953-page-predicate-bounds-warning.md | 13 -
.../24958-migrate-drop-gate-remedies.md | 15 -
.changeset/24960-sqlite-notadb-connect.md | 20 -
.../24961-readonly-probe-unopenable-path.md | 18 -
.changeset/24962-cbp-master-object-read.md | 14 -
.../24964-orphan-detail-operator-command.md | 21 -
.changeset/24965-script-template-tombstone.md | 9 -
.changeset/24968-page-tabs-item-label-key.md | 16 -
.../24969-form-view-title-translation-key.md | 17 -
.changeset/24974-skills-install-line.md | 12 -
...proval-notifications-recipient-language.md | 12 -
.changeset/24978-keyed-notification-text.md | 17 -
.changeset/24979-approvals-label-live.md | 9 -
.../24981-import-lookup-stored-title.md | 12 -
.changeset/24983-template-channel-refusal.md | 18 -
.../24986-cbp-master-edit-object-question.md | 13 -
.changeset/24991-nested-props-non-record.md | 29 --
.changeset/24994-webhook-credential-marker.md | 13 -
.changeset/24996-flow-failed-inner-refusal.md | 16 -
.../24997-webhook-capability-required.md | 18 -
.../25000-strict-stored-expression-refusal.md | 26 --
.../25002-page-predicate-over-budget-error.md | 27 --
.../25009-readable-ids-delegated-compose.md | 19 -
.../25010-readable-ids-nested-compose.md | 15 -
.../25012-cbp-write-shape-master-edit.md | 20 -
...13-select-shorthand-keeps-written-value.md | 26 --
.../25019-sqlite-file-failure-family.md | 24 --
.../25020-turso-encryption-key-local.md | 35 --
.../25030-preflight-issuer-physical-read.md | 16 -
.changeset/25044-cbp-every-master-edit.md | 19 -
.changeset/25049-cbp-every-row-master.md | 31 --
.../25053-oauth-application-type-options.md | 18 -
.../25058-trustproxy-three-consumers.md | 9 -
...25061-notification-template-html-escape.md | 18 -
...25063-delegated-organization-membership.md | 18 -
.../25065-email-service-is-configured.md | 14 -
.../25077-rest-docs-viewer-attr-escape.md | 9 -
.../25079-federated-report-policy-denied.md | 13 -
.../25084-sqlite-refused-connect-release.md | 13 -
.changeset/25096-better-auth-1-7-12.md | 37 --
...25097-cloud-connection-platform-managed.md | 20 -
.changeset/console-cf153eb2e535.md | 81 ----
content/docs/deployment/self-hosting.mdx | 8 +-
content/docs/releases/index.mdx | 2 +-
content/docs/upgrading.mdx | 2 +-
docker/README.md | 8 +-
examples/app-crm/CHANGELOG.md | 35 ++
examples/app-crm/package.json | 2 +-
examples/app-multi-package/CHANGELOG.md | 31 ++
examples/app-multi-package/package.json | 2 +-
examples/app-showcase/CHANGELOG.md | 55 +++
examples/app-showcase/package.json | 2 +-
examples/app-todo/CHANGELOG.md | 51 +++
examples/app-todo/package.json | 2 +-
examples/embed-objectql/CHANGELOG.md | 42 ++
examples/embed-objectql/package.json | 2 +-
packages/adapters/hono/CHANGELOG.md | 32 ++
packages/adapters/hono/package.json | 2 +-
packages/apps/account/CHANGELOG.md | 43 +++
packages/apps/account/package.json | 2 +-
packages/apps/setup/CHANGELOG.md | 43 +++
packages/apps/setup/package.json | 2 +-
packages/apps/studio/CHANGELOG.md | 43 +++
packages/apps/studio/package.json | 2 +-
packages/cli/CHANGELOG.md | 349 +++++++++++++++++
packages/cli/package.json | 2 +-
packages/client-react/CHANGELOG.md | 36 ++
packages/client-react/package.json | 2 +-
packages/client/CHANGELOG.md | 35 ++
packages/client/package.json | 2 +-
packages/cloud-connection/CHANGELOG.md | 83 ++++
packages/cloud-connection/package.json | 2 +-
.../connectors/connector-imap/CHANGELOG.md | 4 +
.../connectors/connector-imap/package.json | 2 +-
.../connectors/connector-mcp/CHANGELOG.md | 35 ++
.../connectors/connector-mcp/package.json | 2 +-
.../connectors/connector-openapi/CHANGELOG.md | 35 ++
.../connectors/connector-openapi/package.json | 2 +-
.../connectors/connector-rest/CHANGELOG.md | 35 ++
.../connectors/connector-rest/package.json | 2 +-
.../connectors/connector-slack/CHANGELOG.md | 35 ++
.../connectors/connector-slack/package.json | 2 +-
packages/console/CHANGELOG.md | 82 ++++
packages/console/package.json | 2 +-
packages/core/CHANGELOG.md | 64 ++++
packages/core/package.json | 2 +-
packages/create-objectstack/CHANGELOG.md | 4 +
packages/create-objectstack/package.json | 2 +-
packages/drivers/driver-memory/CHANGELOG.md | 36 ++
packages/drivers/driver-memory/package.json | 2 +-
packages/drivers/driver-mongodb/CHANGELOG.md | 36 ++
packages/drivers/driver-mongodb/package.json | 2 +-
packages/drivers/driver-sql/CHANGELOG.md | 156 ++++++++
packages/drivers/driver-sql/package.json | 2 +-
.../drivers/driver-sqlite-wasm/CHANGELOG.md | 42 ++
.../drivers/driver-sqlite-wasm/package.json | 2 +-
packages/drivers/driver-turso/CHANGELOG.md | 111 ++++++
packages/drivers/driver-turso/package.json | 2 +-
packages/formula/CHANGELOG.md | 31 ++
packages/formula/package.json | 2 +-
packages/lint/CHANGELOG.md | 267 +++++++++++++
packages/lint/package.json | 2 +-
packages/mcp/CHANGELOG.md | 37 ++
packages/mcp/package.json | 2 +-
packages/metadata-core/CHANGELOG.md | 91 +++++
packages/metadata-core/package.json | 2 +-
packages/metadata-fs/CHANGELOG.md | 8 +
packages/metadata-fs/package.json | 2 +-
packages/metadata-protocol/CHANGELOG.md | 105 +++++
packages/metadata-protocol/package.json | 2 +-
packages/metadata/CHANGELOG.md | 39 ++
packages/metadata/package.json | 2 +-
packages/objectql/CHANGELOG.md | 269 +++++++++++++
packages/objectql/package.json | 2 +-
packages/observability/CHANGELOG.md | 31 ++
packages/observability/package.json | 2 +-
packages/platform-objects/CHANGELOG.md | 190 +++++++++
packages/platform-objects/package.json | 2 +-
packages/plugins/embedder-openai/CHANGELOG.md | 31 ++
packages/plugins/embedder-openai/package.json | 2 +-
.../plugins/knowledge-memory/CHANGELOG.md | 36 ++
.../plugins/knowledge-memory/package.json | 2 +-
.../plugins/knowledge-ragflow/CHANGELOG.md | 36 ++
.../plugins/knowledge-ragflow/package.json | 2 +-
packages/plugins/organizations/CHANGELOG.md | 48 +++
packages/plugins/organizations/package.json | 2 +-
.../plugins/plugin-approvals/CHANGELOG.md | 106 +++++
.../plugins/plugin-approvals/package.json | 2 +-
packages/plugins/plugin-audit/CHANGELOG.md | 86 +++++
packages/plugins/plugin-audit/package.json | 2 +-
packages/plugins/plugin-auth/CHANGELOG.md | 245 ++++++++++++
packages/plugins/plugin-auth/package.json | 2 +-
packages/plugins/plugin-dev/CHANGELOG.md | 80 ++++
packages/plugins/plugin-dev/package.json | 2 +-
packages/plugins/plugin-email/CHANGELOG.md | 74 ++++
packages/plugins/plugin-email/package.json | 2 +-
.../plugins/plugin-hono-server/CHANGELOG.md | 57 +++
.../plugins/plugin-hono-server/package.json | 2 +-
.../plugins/plugin-pinyin-search/CHANGELOG.md | 24 ++
.../plugins/plugin-pinyin-search/package.json | 2 +-
packages/plugins/plugin-security/CHANGELOG.md | 277 ++++++++++++++
packages/plugins/plugin-security/package.json | 2 +-
packages/plugins/plugin-sharing/CHANGELOG.md | 94 +++++
packages/plugins/plugin-sharing/package.json | 2 +-
packages/plugins/plugin-webhooks/CHANGELOG.md | 89 +++++
packages/plugins/plugin-webhooks/package.json | 2 +-
packages/qa/dogfood/CHANGELOG.md | 121 ++++++
packages/qa/dogfood/package.json | 2 +-
packages/qa/downstream-contract/CHANGELOG.md | 31 ++
packages/qa/downstream-contract/package.json | 2 +-
packages/qa/http-conformance/CHANGELOG.md | 9 +
packages/qa/http-conformance/package.json | 2 +-
packages/rest/CHANGELOG.md | 92 +++++
packages/rest/package.json | 2 +-
packages/runtime/CHANGELOG.md | 139 +++++++
packages/runtime/package.json | 2 +-
packages/sdui-parser/CHANGELOG.md | 4 +
packages/sdui-parser/package.json | 2 +-
.../services/service-analytics/CHANGELOG.md | 36 ++
.../services/service-analytics/package.json | 2 +-
.../services/service-automation/CHANGELOG.md | 76 ++++
.../services/service-automation/package.json | 2 +-
packages/services/service-cache/CHANGELOG.md | 36 ++
packages/services/service-cache/package.json | 2 +-
.../service-cluster-redis/CHANGELOG.md | 32 ++
.../service-cluster-redis/package.json | 2 +-
.../services/service-cluster/CHANGELOG.md | 35 ++
.../services/service-cluster/package.json | 2 +-
.../services/service-datasource/CHANGELOG.md | 186 +++++++++
.../services/service-datasource/package.json | 2 +-
packages/services/service-i18n/CHANGELOG.md | 36 ++
packages/services/service-i18n/package.json | 2 +-
packages/services/service-job/CHANGELOG.md | 47 +++
packages/services/service-job/package.json | 2 +-
.../services/service-knowledge/CHANGELOG.md | 35 ++
.../services/service-knowledge/package.json | 2 +-
.../services/service-mailbox/CHANGELOG.md | 49 +++
.../services/service-mailbox/package.json | 2 +-
.../services/service-messaging/CHANGELOG.md | 141 +++++++
.../services/service-messaging/package.json | 2 +-
.../services/service-package/CHANGELOG.md | 37 ++
.../services/service-package/package.json | 2 +-
packages/services/service-print/CHANGELOG.md | 37 ++
packages/services/service-print/package.json | 2 +-
packages/services/service-queue/CHANGELOG.md | 47 +++
packages/services/service-queue/package.json | 2 +-
.../services/service-realtime/CHANGELOG.md | 47 +++
.../services/service-realtime/package.json | 2 +-
.../services/service-settings/CHANGELOG.md | 120 ++++++
.../services/service-settings/package.json | 2 +-
packages/services/service-sms/CHANGELOG.md | 64 ++++
packages/services/service-sms/package.json | 2 +-
.../services/service-storage/CHANGELOG.md | 61 +++
.../services/service-storage/package.json | 2 +-
packages/skills/CHANGELOG.md | 13 +
packages/skills/package.json | 2 +-
packages/spec/CHANGELOG.md | 361 ++++++++++++++++++
packages/spec/package.json | 2 +-
packages/triggers/trigger-api/CHANGELOG.md | 35 ++
packages/triggers/trigger-api/package.json | 2 +-
.../trigger-record-change/CHANGELOG.md | 35 ++
.../trigger-record-change/package.json | 2 +-
.../triggers/trigger-schedule/CHANGELOG.md | 38 ++
.../triggers/trigger-schedule/package.json | 2 +-
packages/types/CHANGELOG.md | 31 ++
packages/types/package.json | 2 +-
packages/verify/CHANGELOG.md | 132 +++++++
packages/verify/package.json | 2 +-
skills/objectstack-ai/SKILL.md | 2 +-
skills/objectstack-api/SKILL.md | 2 +-
skills/objectstack-automation/SKILL.md | 2 +-
skills/objectstack-data/SKILL.md | 2 +-
skills/objectstack-formula/SKILL.md | 2 +-
skills/objectstack-i18n/SKILL.md | 2 +-
skills/objectstack-platform/SKILL.md | 2 +-
skills/objectstack-query/SKILL.md | 2 +-
skills/objectstack-ui/SKILL.md | 2 +-
skills/objectstack-upgrade/SKILL.md | 2 +-
288 files changed, 6168 insertions(+), 2141 deletions(-)
delete mode 100644 .changeset/24583-7a2-organization-less-grant-readers-retired.md
delete mode 100644 .changeset/24583-7a3-sharing-null-readers.md
delete mode 100644 .changeset/24583-7b3-organizationless-counter-retired.md
delete mode 100644 .changeset/24583-7z-closing-d8-texts.md
delete mode 100644 .changeset/24583-tenant-column-not-null-ddl.md
delete mode 100644 .changeset/24690-stored-expression-dropped-fields.md
delete mode 100644 .changeset/24692-add-record-retired.md
delete mode 100644 .changeset/24707-pg-multischema-introspect.md
delete mode 100644 .changeset/24720-validation-rule-parent.md
delete mode 100644 .changeset/24728-filterable-fields-residue.md
delete mode 100644 .changeset/24750-invite-role-not-found-code.md
delete mode 100644 .changeset/24751-remind-notified-per-person.md
delete mode 100644 .changeset/24757-fold-warning-retirement.md
delete mode 100644 .changeset/24758-password-param-type.md
delete mode 100644 .changeset/24760-action-state-visibility.md
delete mode 100644 .changeset/24762-impersonation-credential-posture.md
delete mode 100644 .changeset/24765-team-sessions-related-list.md
delete mode 100644 .changeset/24766-auth-brand-binding.md
delete mode 100644 .changeset/24766-branding-settings-v2.md
delete mode 100644 .changeset/24766-runtime-config-branding-settings.md
delete mode 100644 .changeset/24766-setup-branding-entry.md
delete mode 100644 .changeset/24767-settings-test-send-recipient.md
delete mode 100644 .changeset/24772-primary-datasource-test-sync.md
delete mode 100644 .changeset/24773-oauth-application-type.md
delete mode 100644 .changeset/24773-oauth-form-types.md
delete mode 100644 .changeset/24773-oauth-register-refusal-status.md
delete mode 100644 .changeset/24773-oauth-type-refusal.md
delete mode 100644 .changeset/24774-rotate-secret-cut-off-copy.md
delete mode 100644 .changeset/24775-permission-set-assignment-person-title.md
delete mode 100644 .changeset/24782-advisory-unevaluable-log.md
delete mode 100644 .changeset/24785-mention-recipients-read-gated.md
delete mode 100644 .changeset/24796-list-view-binding-explanations.md
delete mode 100644 .changeset/24799-vendor-client-address-trust.md
delete mode 100644 .changeset/24808-user-field-foreign-reference-warning.md
delete mode 100644 .changeset/24809-cors-doors-one-admission-rule.md
delete mode 100644 .changeset/24813-adopt-orphans-readonly-package.md
delete mode 100644 .changeset/24817-later-step-original-submitter.md
delete mode 100644 .changeset/24818-trustproxy-consumers-describe.md
delete mode 100644 .changeset/24821-operator-no-org-insert-403.md
delete mode 100644 .changeset/24822-update-repoint-honoured.md
delete mode 100644 .changeset/24822-wall-judged-organization-stored.md
delete mode 100644 .changeset/24827-migrate-apply-birth-marker.md
delete mode 100644 .changeset/24833-dev-recover-line-names-no-origin.md
delete mode 100644 .changeset/24835-driver-update-leaves-caller-data.md
delete mode 100644 .changeset/24835-webhook-lost-delivery-error.md
delete mode 100644 .changeset/24844-google-env-credentials-save.md
delete mode 100644 .changeset/24863-approval-labels-reader-locale.md
delete mode 100644 .changeset/24865-auth-sms-template-organization.md
delete mode 100644 .changeset/24865-template-recipient-organization.md
delete mode 100644 .changeset/24877-bridge-transport.md
delete mode 100644 .changeset/24882-settings-format-refusal.md
delete mode 100644 .changeset/24896-prelude-grant-read-projection.md
delete mode 100644 .changeset/24899-archived-mail-bodies.md
delete mode 100644 .changeset/24900-webhook-label-description-live.md
delete mode 100644 .changeset/24909-missing-driver-client-remedy.md
delete mode 100644 .changeset/24910-declared-webhook-undeliverable.md
delete mode 100644 .changeset/24915-approval-step-translation-key.md
delete mode 100644 .changeset/24923-sys-notification-subscription-retired.md
delete mode 100644 .changeset/24924-template-channel-email-sms.md
delete mode 100644 .changeset/24925-plan-names-ownership-ceremony.md
delete mode 100644 .changeset/24936-create-user-dialog-copy.md
delete mode 100644 .changeset/24937-optional-driver-package-remedy.md
delete mode 100644 .changeset/24938-sys-user-security-tab-own-record.md
delete mode 100644 .changeset/24939-bu-parent-delete-restrict.md
delete mode 100644 .changeset/24944-export-lookup-title-field.md
delete mode 100644 .changeset/24945-export-boolean-locale.md
delete mode 100644 .changeset/24948-organization-delete-refuse-nonempty.md
delete mode 100644 .changeset/24949-migrate-apply-confirmation-exit.md
delete mode 100644 .changeset/24950-sqlite-open-failure-no-stepdown.md
delete mode 100644 .changeset/24951-duplicate-message-labels.md
delete mode 100644 .changeset/24953-page-predicate-bounds-warning.md
delete mode 100644 .changeset/24958-migrate-drop-gate-remedies.md
delete mode 100644 .changeset/24960-sqlite-notadb-connect.md
delete mode 100644 .changeset/24961-readonly-probe-unopenable-path.md
delete mode 100644 .changeset/24962-cbp-master-object-read.md
delete mode 100644 .changeset/24964-orphan-detail-operator-command.md
delete mode 100644 .changeset/24965-script-template-tombstone.md
delete mode 100644 .changeset/24968-page-tabs-item-label-key.md
delete mode 100644 .changeset/24969-form-view-title-translation-key.md
delete mode 100644 .changeset/24974-skills-install-line.md
delete mode 100644 .changeset/24978-approval-notifications-recipient-language.md
delete mode 100644 .changeset/24978-keyed-notification-text.md
delete mode 100644 .changeset/24979-approvals-label-live.md
delete mode 100644 .changeset/24981-import-lookup-stored-title.md
delete mode 100644 .changeset/24983-template-channel-refusal.md
delete mode 100644 .changeset/24986-cbp-master-edit-object-question.md
delete mode 100644 .changeset/24991-nested-props-non-record.md
delete mode 100644 .changeset/24994-webhook-credential-marker.md
delete mode 100644 .changeset/24996-flow-failed-inner-refusal.md
delete mode 100644 .changeset/24997-webhook-capability-required.md
delete mode 100644 .changeset/25000-strict-stored-expression-refusal.md
delete mode 100644 .changeset/25002-page-predicate-over-budget-error.md
delete mode 100644 .changeset/25009-readable-ids-delegated-compose.md
delete mode 100644 .changeset/25010-readable-ids-nested-compose.md
delete mode 100644 .changeset/25012-cbp-write-shape-master-edit.md
delete mode 100644 .changeset/25013-select-shorthand-keeps-written-value.md
delete mode 100644 .changeset/25019-sqlite-file-failure-family.md
delete mode 100644 .changeset/25020-turso-encryption-key-local.md
delete mode 100644 .changeset/25030-preflight-issuer-physical-read.md
delete mode 100644 .changeset/25044-cbp-every-master-edit.md
delete mode 100644 .changeset/25049-cbp-every-row-master.md
delete mode 100644 .changeset/25053-oauth-application-type-options.md
delete mode 100644 .changeset/25058-trustproxy-three-consumers.md
delete mode 100644 .changeset/25061-notification-template-html-escape.md
delete mode 100644 .changeset/25063-delegated-organization-membership.md
delete mode 100644 .changeset/25065-email-service-is-configured.md
delete mode 100644 .changeset/25077-rest-docs-viewer-attr-escape.md
delete mode 100644 .changeset/25079-federated-report-policy-denied.md
delete mode 100644 .changeset/25084-sqlite-refused-connect-release.md
delete mode 100644 .changeset/25096-better-auth-1-7-12.md
delete mode 100644 .changeset/25097-cloud-connection-platform-managed.md
delete mode 100644 .changeset/console-cf153eb2e535.md
diff --git a/.changeset/24583-7a2-organization-less-grant-readers-retired.md b/.changeset/24583-7a2-organization-less-grant-readers-retired.md
deleted file mode 100644
index cd50487b8df..00000000000
--- a/.changeset/24583-7a2-organization-less-grant-readers-retired.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-'@objectstack/core': minor
-'@objectstack/plugin-security': minor
-'@objectstack/runtime': patch
----
-
-feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.
-
-- **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
-- **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
-- **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
-- **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
-- **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
-- **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.
-
-**What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
diff --git a/.changeset/24583-7a3-sharing-null-readers.md b/.changeset/24583-7a3-sharing-null-readers.md
deleted file mode 100644
index afff86eb22f..00000000000
--- a/.changeset/24583-7a3-sharing-null-readers.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/plugin-sharing': minor
-'@objectstack/plugin-approvals': minor
-'@objectstack/metadata-protocol': minor
----
-
-feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
-
-Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
-
-
-
-**BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
-
-- **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
-- **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
-- **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
-- **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
-- **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
-- **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
-
-Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
diff --git a/.changeset/24583-7b3-organizationless-counter-retired.md b/.changeset/24583-7b3-organizationless-counter-retired.md
deleted file mode 100644
index 60c1fb88c2e..00000000000
--- a/.changeset/24583-7b3-organizationless-counter-retired.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-'@objectstack/cli': minor
-'@objectstack/driver-sql': minor
-'@objectstack/driver-turso': minor
-'@objectstack/objectql': minor
-'@objectstack/lint': patch
-'@objectstack/spec': patch
----
-
-feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
-
-- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
-- **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
-- **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
-- **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
-
-**The remedy.**
-- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
-- A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
-
-The retired export:
-
-- FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
diff --git a/.changeset/24583-7z-closing-d8-texts.md b/.changeset/24583-7z-closing-d8-texts.md
deleted file mode 100644
index 6d98e7642e6..00000000000
--- a/.changeset/24583-7z-closing-d8-texts.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@objectstack/spec': patch
-'@objectstack/lint': patch
-'@objectstack/objectql': patch
-'@objectstack/runtime': patch
-'@objectstack/plugin-security': patch
-'@objectstack/plugin-sharing': patch
-'@objectstack/plugin-webhooks': patch
-'@objectstack/service-automation': patch
-'@objectstack/service-messaging': patch
-'@objectstack/service-storage': patch
----
-
-fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
-
-Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
-
-ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
-
-- **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
-- **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
-- **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
-- **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
-- **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
-- **The other packages.** Code comments only.
diff --git a/.changeset/24583-tenant-column-not-null-ddl.md b/.changeset/24583-tenant-column-not-null-ddl.md
deleted file mode 100644
index ad64e6dcf72..00000000000
--- a/.changeset/24583-tenant-column-not-null-ddl.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-'@objectstack/driver-sql': patch
-'@objectstack/driver-turso': patch
-'@objectstack/spec': patch
----
-
-feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database
-
-Clause-②: yes (narrowing)
-Metadata-protocol: none
-
-
-
-**BREAKING** accept-set narrowing at the database (ADR-0131 D1: "There is no nullable tenant column anywhere"). It changes what a data writer can store, not what a metadata author can write, so it ships as `patch` with this banner.
-
-- **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns.
-- **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed:
.organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict.
-- **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. On a database that has not taken the ceremony, a table this release creates (a newly rotated shard, or a cold table a newly configured lifecycle tier creates) is NOT NULL beside nullable siblings: an organization-less row the lifecycle mover would carry into it is refused there, and stays where it is until the ceremony attributes it. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
-- **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly.
-
-**The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`.
diff --git a/.changeset/24690-stored-expression-dropped-fields.md b/.changeset/24690-stored-expression-dropped-fields.md
deleted file mode 100644
index b7cbdf75d83..00000000000
--- a/.changeset/24690-stored-expression-dropped-fields.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/objectql': minor
-'@objectstack/service-automation': patch
----
-
-feat(objectql): a caller value that a stored field's `expression` replaced is reported through `onFieldsDropped` / `droppedFields` under the new `DroppedFieldsEvent.reason` value `stored_expression`
-
-Clause-②: yes (widening)
-
-The engine computes a stored (non-`formula`) field's declared `expression` on every write, so a value the caller sends for such a field never stands. Until now nothing said so. `onFieldsDropped` stayed silent, so the PATCH and `/batch` doors answered with no `droppedFields`, and a flow's `update_record` reported success over a row that holds the computed value instead of the one sent.
-
-- **New `reason` value.** `DroppedFieldsEventSchema.reason` (`@objectstack/spec/data`) gains `stored_expression`. It means the field is a stored column the engine computes from its `expression`, and the caller's value was replaced by the computed one. The field IS written, so `computed` (a `formula` with no column, whose key is dropped) would be wrong for it. The four existing values are unchanged.
-- **When it is reported.** On insert (single, batch, and `insertMany`'s per-row `droppedFields`), on update by id, and on a predicate update, in every context, `isSystem` included. It is reported only when the caller sent the field and the value stored differs from the caller's value. A client that sends the computed value (the console's line-item grid) or no value is told nothing. A value another strip already took, such as a stored expression field that is also `readonly`, is reported once, under that strip's reason.
-- **`strictReadonlyWrites` is unchanged.** The maintainer's ruling 「甲」 says only the loud half ships in 18.x. A strict write whose value for such a field differs from the computed one is NOT refused: it completes with the computed value, and the replacement is reported through `onFieldsDropped` under strict as well. A refusal is runtime behaviour that no conversion can carry, so the strict refusal of a `stored_expression` replacement is deferred to 19.0. A strict write that also carries a refusable drop (`readonly`, `readonly_when`, `primary_key`, `computed`) is refused as before, for those drops only, and fires no listener.
-- **Flow step warning.** `@objectstack/service-automation` words the new reason. Its warning says the requested value(s) were not stored, rather than that the field was not written, and its closing sentence says the write succeeded with the computed value(s).
-
-**For consumers that branch on `reason`.** A `Record` table fails its typecheck until it has a `stored_expression` row. That is the intended signal: add the row. A client running behind the server should treat a reason it does not recognise as "not applied", never as one of the known reasons.
diff --git a/.changeset/24692-add-record-retired.md b/.changeset/24692-add-record-retired.md
deleted file mode 100644
index 1c7225c06f6..00000000000
--- a/.changeset/24692-add-record-retired.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/lint': patch
----
-
-feat(spec)!: a list's `addRecord` block says what renders — `enabled` is required, `position` defaults to `top`, and `mode` / `formView` are retired
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes, and in 18.x under the standing rule for breaking narrowings: an ADR-0087 conversion carries every body that parsed under 18.0, so no stored row or built artifact is refused.
-
-`AddRecordConfigSchema` — the `addRecord` block on a list view, an object's or a view container's `listViews`, a list page's `interfaceConfig` and a page `list-view` node — declared defaults the renderer never applied (`enabled` `true`, `position` `bottom`, `mode` `inline`) and two keys no renderer read. The list's Add button is drawn only for an explicit `enabled: true`, an absent `position` renders at the top, and every Add opens the object's create form. The block now says exactly that:
-
-- **`enabled` is REQUIRED, with no default.** `addRecord: {}` is refused at parse (`os validate`, `os build`, the save doors) with a prescription naming the key, instead of drawing nothing.
-- **`position` defaults to `top`**, where the renderer has always placed an absent position. Values stay `top` / `bottom` / `both`. No screen moves.
-- **`mode` and `formView` are `retiredKey()` tombstones** (ADR-0049 enforce-or-remove): authoring either is a `tsc` error and a parse error carrying the prescription. The create form is chosen by convention instead: the create entry opens `formViews.create` when the object's view container declares it, else `form` (or `formViews.default`). How that form opens stays the renderer's.
-
-### FROM → TO
-
-| before | after |
-| --- | --- |
-| `addRecord: {}` (parsed as `enabled: true`, drew nothing) | refused: write `addRecord: { enabled: true }` |
-| `addRecord: { position: 'bottom' }` | refused: write `addRecord: { enabled: true, position: 'bottom' }` |
-| `addRecord: { enabled: true }` (parsed `position: 'bottom'`) | parses as `position: 'top'`, where it always rendered |
-| `addRecord: { enabled: true, mode: 'form' }` (any `mode`) | `addRecord: { enabled: true }` |
-| `addRecord: { enabled: true, formView: 'create' }` | `addRecord: { enabled: true }`, with the form declared as `formViews.create` on the object's view container |
-
-**The one-line fix:** delete `mode` and `formView`, and write `enabled: true` (or `enabled: false`) on every `addRecord` block. `os migrate meta --from 17` lists the edits for existing metadata sources; `--write` applies the ones it can prove, and you apply the rest by hand.
-
-### The ADR-0087 disposition
-
-- D2 `add-record-mode-form-view-removed` strips `mode` and `formView` from every `addRecord` block it reaches (`stack.views[]` in all three persisted spellings, `objects[].listViews.*`, `pages[].interfaceConfig` and every page `list-view` node), with a notice per key. Lossless in pixels: no renderer read either key.
-- D2 `add-record-enabled-defaulted` writes `enabled: true`, the default the 18.0 spec declared, onto a block that omits `enabled`, with a notice. Measured before choosing it: no producer in this repository (the showcase's two blocks, the lint fixture) or in objectui's Studio design surface writes `addRecord` without `enabled`, so the conversion carries the narrowing with a zero measured population. **One visible consequence, stated:** a stored block that omitted `enabled` drew no Add button, because the renderer read the raw body; converted, it draws the button its declaration always promised. To keep a list without one, write `enabled: false` or drop the block. An explicit `enabled` value is never touched.
-- Both are retired from the load path: a live author is refused at parse and taught; stored rows, built artifacts inside their declared-floor window and `os migrate meta --from 17` replay them.
-- `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/AddRecordConfig:mode` and `ui/AddRecordConfig:formView`; the D3 entries are `add-record-mode-form-view-retired` and `add-record-enabled-required`; the default moves (`enabled` `true` → none, `position` `bottom` → `top`) are declared in `DEFAULT_CHANGES_BY_MAJOR[18]`.
-
-### Also in this change
-
-- The Studio view form and page form offer exactly `enabled` and `position` for `addRecord`.
-- `@objectstack/lint`: `os explain list-view-field-unknown` no longer names `addRecord.formView` as a position another rule checks.
-- The showcase's two `addRecord` blocks and the docs (`ui/create-vs-edit-form`, `ui/views`) drop `mode` / `formView` and state the create-form convention.
diff --git a/.changeset/24707-pg-multischema-introspect.md b/.changeset/24707-pg-multischema-introspect.md
deleted file mode 100644
index a2b25affdd5..00000000000
--- a/.changeset/24707-pg-multischema-introspect.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/driver-sql': minor
----
-
-fix(driver-sql)!: on PostgreSQL, `introspectSchema` reads each table's columns in the table's own schema, and refuses a `search_path` that reaches one table name in two schemas instead of silently dropping one
-
-`introspectSchema` lists the base tables of every schema in `current_schemas(false)` and keys the answer by bare table name. Two things went wrong when a connection's `search_path` named more than one schema (`ALTER DATABASE … SET search_path = public, sales`). First, each table's columns came from knex `columnInfo()` on the bare name, which reads only the first schema on the path, so a table that lives only in a later schema (`sales.orders`) was listed with no columns. `listRemoteTables` answered it `columnCount: 0`, and a draft built from it had no fields. Second, a same-named table in a later schema (`sales.customers` behind `public.customers`) was dropped without a word, because one key cannot hold two tables. The PostgreSQL branch now reads every table's columns in its own schema. When one bare name is reached in more than one schema, it refuses the whole introspection with `RESOURCE_CONFLICT` / `409`, naming each colliding table with its schemas in `search_path` order. A connection that reaches one schema, the default `public` deployment included, answers exactly what it answered before. The SQLite and MySQL branches are unchanged.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (introspection's accept set narrows on PostgreSQL), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** a PostgreSQL connection whose `search_path` reached a table name in two schemas got a successful `introspectSchema()` answer holding one of the two tables (the one in the earliest schema on the path), and so did every caller built on it: `listRemoteTables`, object drafting, catalog refresh and the external datasource's connection test.
-- **TO:** the same call throws `RESOURCE_CONFLICT` (`status: 409`), naming each colliding table and its schemas, for example `'customers' ('public', 'sales')`. Those callers surface that error.
-- **Also changed (a wrong answer made right, no accept set moved):** a table that lives only in a later schema on the path now reports its own columns where it reported none.
-- **Unchanged:** a connection that reaches one schema, the default `public` deployment included. One schema cannot hold two same-named tables, so it is never refused, and its answer is byte-identical to before.
-- **Fix:** point the connection at one schema (the datasource's `schema` setting sets its `search_path`), or rename one of the two tables on the remote database.
diff --git a/.changeset/24720-validation-rule-parent.md b/.changeset/24720-validation-rule-parent.md
deleted file mode 100644
index 2efe9027f54..00000000000
--- a/.changeset/24720-validation-rule-parent.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-"@objectstack/lint": patch
----
-
-fix(lint): `os validate`, `os build` and the object save door warn on a `validations[]` rule whose predicate reads `parent` (#24720)
-
-Clause-②: no
-
-A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, passed `os validate` silently, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included.
-
-The build's expression rule (`validateStackExpressions`) now reports such a predicate at `warning`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass and lists the same finding among its advisories: the same rule id (`expression-invalid`), location and message.
-
-**Nothing new is refused in 18.x.** A warning fails neither `os build` nor `os validate`, and a publish-mode object save that carries such a rule is still admitted. No ADR-0087 conversion can rewrite a predicate that reads `parent`, so under the post-GA rule for such narrowings only this warning ships in 18.x. The refusal, the same finding at `error`, is planned for 19.0. It will also cover a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true, because the verdict judges the rule as written.
-
-**Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail.
-
-**Unchanged.**
-
-- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this verdict is withdrawn in that same change.
-- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, as before, and so is a `record` column spelled like the root (`record.parent_code`).
-- No example or package object in this repository authors `parent` in a `validations[]` rule.
-- No public export or signature moves.
diff --git a/.changeset/24728-filterable-fields-residue.md b/.changeset/24728-filterable-fields-residue.md
deleted file mode 100644
index ecb2796b7da..00000000000
--- a/.changeset/24728-filterable-fields-residue.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-'@objectstack/lint': minor
-'@objectstack/metadata-protocol': patch
-'@objectstack/metadata-core': patch
----
-
-fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
-
-`@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
-
-- **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
-- **The react-page props check** read `` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
-
-Both now refuse the key by name, as an `error`, whatever it names:
-
-- a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
-- `` on a react page reports `react-prop-retired`.
-
-The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
-
-### FROM → TO
-
-| before | after |
-| --- | --- |
-| `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
-| `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
-| ` ` on a react page: passed every authoring command | `react-prop-retired` |
-
-**The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
-
-### The residue readers that go
-
-- **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
-- **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
-- **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
-
-⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
diff --git a/.changeset/24750-invite-role-not-found-code.md b/.changeset/24750-invite-role-not-found-code.md
deleted file mode 100644
index 8b8d81b4285..00000000000
--- a/.changeset/24750-invite-role-not-found-code.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/plugin-auth': minor
-'@objectstack/spec': minor
----
-
-fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`
-
-Clause-②: yes (widening)
-
-- **Before.** An invitation naming a role outside the closed membership vocabulary was refused `400` with only a message (`ROLE_NOT_FOUND: `) and no `code`, so a client that branches on `error.code` (ADR-0112) could not recognise it. `update-member-role` already answered the same refusal with `code: 'ROLE_NOT_FOUND'`.
-- **Now.** invite-member answers `400` with `code: 'ROLE_NOT_FOUND'` and the same message, naming the role, the body `update-member-role` gives for the same condition. The refusal still happens before any invitation row is written. This is the answer the invitation schema's `role` docstring and the authentication docs already promise.
-- **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
-- **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.
diff --git a/.changeset/24751-remind-notified-per-person.md b/.changeset/24751-remind-notified-per-person.md
deleted file mode 100644
index 1e73fa3ca8e..00000000000
--- a/.changeset/24751-remind-notified-per-person.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-approvals': patch
----
-
-fix(plugin-approvals): a reminder's `notified` counts each approver address once, not once per slate slot
-
-Clause-②: no
-
-`POST /api/v1/approvals/requests/:id/remind` (`ApprovalService.remind()`) answers `notified` as the number of reminders it handed to messaging: one per distinct approver address on the pending slate. A `per_group` slate lists a person once per group they resolved into, and `pending_approvers` keeps every one of those slots until the first vote, so a person placed in two groups was counted twice while messaging delivered them one reminder. They are now reminded once and counted once, the rule the opening `approval.requested` notice already applies.
-
-A user id and the same account's email are still two addresses: the person holds two slots and receives two reminders, exactly as at the opening. Unchanged: who is reminded, each reminder's topic, payload, deep link and dedup key, the throttle, the submitter check, the `remind` audit row, and `position:*`-style literals, which still notify no one and add nothing to the count.
diff --git a/.changeset/24757-fold-warning-retirement.md b/.changeset/24757-fold-warning-retirement.md
deleted file mode 100644
index 58b1ad0ada6..00000000000
--- a/.changeset/24757-fold-warning-retirement.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): the `position_name_fold_grant` warning says the same-name grant is removed in the next major version, and what holders lose then
-
-Clause-②: no (a runtime warning's wording plus one added field in its log payload; no accept set, served shape, resolution result or error code moves)
-
-- **What it was.** A position resolves the permission set that has its own name even when the position's definition does not list that set in `permissionSets`. `plugin-security` has warned about this since 17.3 (`position_name_fold_grant`), once per position. The warning said the grant was "in force and ungoverned" and asked for `permissionSets: ['P']` or a rename. It did not say that this name-based grant is being retired, or what happens to holders when it is.
-- **What changed.** The warning now says three things. The set is in force for holders of position `P` only because the two share a name. This name-based grant is deprecated and is removed in the next major version: from then on, holders of `P` do not receive set `P` unless `P`'s definition names it. To keep the grant, declare `permissionSets: ['P']` on position `P`; if the shared name is a coincidence, rename one of the two. The structured payload adds `removedIn: 'next_major'`. `event`, `position` and `permissionSet` are unchanged.
-- **Why only a warning in 18.x.** No ADR-0087 conversion can carry this retirement: the platform cannot write `permissionSets` into a sealed managed position definition (ADR-0131 D6). So 18.x ships the warning, and the removal of the name-based grant waits for 19.0.
-- **Unchanged.** Every resolution result. A holder of `P` still receives set `P` in 18.x. The warning still fires under the same conditions, including for a position bound to a different set that also shares its name with one. It stays silent for a declared pair, a baseline set and a position whose name matches no set, and it still fires once per position name until the next restart.
-- **What to do.** For each position named in a `position_name_fold_grant` warning, add the same-named set to that position's `permissionSets` before upgrading to the next major version. Setup's Permission Sets field and the set's Assigned Users panel then show the binding as well.
diff --git a/.changeset/24758-password-param-type.md b/.changeset/24758-password-param-type.md
deleted file mode 100644
index 317b53a47ab..00000000000
--- a/.changeset/24758-password-param-type.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): the password an administrator or user types into an action dialog is masked, no longer shown in clear text
-
-Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves)
-
-Twelve credential params on the identity objects were declared `type: 'text'`, so the console's action dialog drew each as a plain text input and the typed password was readable on screen and in screenshots. Each now declares `type: 'password'`, which the dialog draws as a masked input:
-
-- `sys_user`: `create_user`'s `password` and `set_user_password`'s `newPassword` (Setup › Users), `change_my_password`'s `currentPassword` and `newPassword`, and the `password` of `delete_my_account`, `enable_two_factor`, `disable_two_factor` and `generate_backup_codes`.
-- `sys_two_factor`: the `password` of `enable_two_factor`, `disable_two_factor` and `regenerate_backup_codes`.
-- `sys_sso_provider`: `register_sso_provider`'s `clientSecret`.
-
-Labels, names, `required` and the posted body are unchanged: the value is still posted as the string typed, and no endpoint changes what it accepts.
diff --git a/.changeset/24760-action-state-visibility.md b/.changeset/24760-action-state-visibility.md
deleted file mode 100644
index a3fd1a96710..00000000000
--- a/.changeset/24760-action-state-visibility.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
-
-Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
-
-Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
-
-- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
-- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
-- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
-
-The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
-
-Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
diff --git a/.changeset/24762-impersonation-credential-posture.md b/.changeset/24762-impersonation-credential-posture.md
deleted file mode 100644
index f1f7a5a8d86..00000000000
--- a/.changeset/24762-impersonation-credential-posture.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): an administrator's impersonation session is not held by the target's forced password reset or password expiry (#24762)
-
-Clause-②: no
-
-Impersonating a user who must change their password used to strand the administrator: the impersonation session was refused `403 PASSWORD_RESET_REQUIRED` on every protected route, and the console showed the target's change-password screen. An impersonation session is minted from the administrator's own session, which has passed its own posture check, so it is not the user's password sign-in, and a `credential`-scope posture holds the password sign-in alone (`AUTH_GATE_SCOPES`).
-
-- **What moves.** On a session that carries `impersonatedBy`, the session gate (`user.authGate`, stamped in `customSession`) now shows only the first `account`-scope posture the user holds. `PASSWORD_RESET_REQUIRED` and `PASSWORD_EXPIRED` no longer hold it. `MFA_REQUIRED` still does, so an impersonation session of a user who owes MFA enrolment is still refused `403 MFA_REQUIRED`, including when the user also owes a password change.
-- **Unchanged.** The user's own sign-in stays held until they change the password. The forced reset's revocation (`revokeSessionsAndKeys`, on by default) still ends every session of the user, impersonation sessions included. API keys, the print door's refusal of an impersonation session, and the impersonation door's own check of the administrator's posture are unchanged.
diff --git a/.changeset/24765-team-sessions-related-list.md b/.changeset/24765-team-sessions-related-list.md
deleted file mode 100644
index 75b1a2bbcd6..00000000000
--- a/.changeset/24765-team-sessions-related-list.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/platform-objects': minor
----
-
-fix(platform-objects): a team record no longer lists Sessions with "Sign out other devices" (#24765)
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** for a reader of a team's derived related lists only: Setup → Teams → a team → Related no longer lists *Sessions*. Shipped as `minor` under the launch-window convention for accept-set narrowings.
-
-- **Before.** `sys_session.active_team_id` is a lookup to `sys_team` that did not set `relatedList: false`, so every team record derived a Sessions related list. That list carried the session list's only toolbar action, "Sign out other devices", whose confirmation reads "Sign out of every other device where you're currently logged in?". The action signs out the administrator's own other sessions, not the team's.
-- **Now.** `active_team_id` sets `relatedList: false`, so a team record derives no Sessions list. A team's Members and Invitations lists are unchanged.
-- **Unchanged.** The `active_team_id` field, its lookup to `sys_team`, its label and its column: better-auth still writes `activeTeamId` there. A user record's Sessions tab (keyed on `user_id`), where "Sign out other devices" belongs, and the action itself.
-
-**What to do.** Nothing. No team-scoped session action existed to lose.
diff --git a/.changeset/24766-auth-brand-binding.md b/.changeset/24766-auth-brand-binding.md
deleted file mode 100644
index 1b207da830d..00000000000
--- a/.changeset/24766-auth-brand-binding.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/plugin-auth': minor
----
-
-fix(plugin-auth): the auth product name binds to `branding.workspace_name` only where the settings service offers the `branding` namespace, so a walled boot no longer warns about it
-
-Clause-②: no (this package stops reading a namespace `@objectstack/service-settings` no longer offers under a wall; that withholding, declared BREAKING in its changeset, is what narrows the walled deployment, and nothing here widens)
-
-`AuthPlugin` binds the product name in sign-in emails, the authenticator app entry and the MCP sign-in to the `branding.workspace_name` setting. `@objectstack/service-settings` (same release) no longer offers the `branding` namespace under the `group` and `isolated` tenancy postures, so on such a deployment that read would fail on every boot and log `Auth: failed to apply branding.workspace_name: No settings manifest registered for namespace 'branding'.` at `warn`.
-
-The binding is now made only when the settings service lists the `branding` namespace, the same test the runtime config makes. On a single-organization deployment a saved name, or one pinned with `OS_BRANDING_WORKSPACE_NAME`, applies as before.
-
-**On a walled deployment (`group` / `isolated`) the auth product name now comes from the `OS_APP_NAME` chain alone.** Nothing is read from the `branding` namespace and nothing is logged. Two sources that used to name sign-in emails, the authenticator issuer and the MCP sign-in there stop doing so:
-
-- `OS_BRANDING_WORKSPACE_NAME`. The settings service served it as an `env` value whatever the posture, and this plugin applied it.
-- A `branding.workspace_name` row stored with no organization, for example one saved before the wall stood.
-
-Migration, FROM → TO: `OS_BRANDING_WORKSPACE_NAME=Acme` → `OS_APP_NAME=Acme`. Set `OS_APP_NAME` to the name the deployment's sign-in emails and authenticator entries should carry. A deployment that set neither source sees no change.
diff --git a/.changeset/24766-branding-settings-v2.md b/.changeset/24766-branding-settings-v2.md
deleted file mode 100644
index 9da30c12508..00000000000
--- a/.changeset/24766-branding-settings-v2.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-'@objectstack/service-settings': minor
----
-
-fix(service-settings)!: the Branding settings drive the console's name, logo and colour on a single-organization deployment, retire `theme_mode` and `support_email`, and are not offered under a walled tenancy posture
-
-Clause-②: no (narrowing)
-
-
-
-Setup → Branding saved values that nothing in the console ever read. Under the maintainer's ruling, its three keys now drive the console's own branding through the runtime config (`@objectstack/cloud-connection`, same release), the two keys with no counterpart retire, and the page is offered only where no organization wall stands.
-
-**What the three keys do now** (a single-organization deployment, the `single` posture):
-
-- `workspace_name` is the product name in the console, on the sign-in page and in the browser title, and it stays the product name in sign-in emails, the authenticator app and MCP sign-in.
-- `logo_url` is the logo on the sign-in page, the loading screen and the home header.
-- `accent_color` is the browser and installed-app theme colour. Each app keeps the colours its own `AppBranding` declares.
-- A key nobody set falls back to the deployment's own branding (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or the host's `RuntimeConfigPlugin` options). A value pinned with the settings layer's `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` or `OS_BRANDING_ACCENT_COLOR` counts as set. A save shows on the next page load, with no restart. `accent_color` no longer declares the `#6366f1` default, which the page showed as a colour the console never used.
-
-**BREAKING: `theme_mode` and `support_email` are retired.** The Branding manifest (`brandingSettingsManifest`) drops both specifiers and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles, and its `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed. Nothing ever read either key. A value a workspace already stored is kept:
-
-- The `sys_setting` row stays exactly as it was. No read or save rewrites or deletes it, and it never blocks saving the live keys.
-- `GET /api/settings/branding` does not resolve it: the key is absent from both `manifest.specifiers` and `values`.
-- A `PUT /api/settings/branding` that names `theme_mode` or `support_email` is refused with `400 UNKNOWN_KEY` (`details.key` names it). The refusal covers the whole batch, so a live key sent beside it does not land either.
-- `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read on any deployment.
-
-**BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's: the console's through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`, and the auth product name through `OS_APP_NAME`.
-
-**On a walled deployment the `OS_BRANDING_*` variables are no longer read.** With the namespace withheld, none of `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` and `OS_BRANDING_ACCENT_COLOR` reaches a reader there. One of them had an effect before this release: `OS_BRANDING_WORKSPACE_NAME` named sign-in emails, the authenticator issuer and the MCP sign-in (`@objectstack/plugin-auth` applied it as an `env` value whatever the posture). So did a `branding.workspace_name` row stored with no organization. Both now give way to the `OS_APP_NAME` chain. The fix is in the deployment's environment, not in any code or metadata: a walled deployment that set `OS_BRANDING_WORKSPACE_NAME=Acme` sets `OS_APP_NAME=Acme` instead. The other two variables had no reader before and have none on a walled deployment now. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule.
-
-**What to do after upgrading.**
-
-- On a single-organization deployment nothing has to be rewritten.
-- There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`.
-- On a walled deployment, set the console's branding with `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. If you set `OS_BRANDING_WORKSPACE_NAME` there, move its value to `OS_APP_NAME`.
-
-It ships as `minor` under the launch-window convention for narrowings.
diff --git a/.changeset/24766-runtime-config-branding-settings.md b/.changeset/24766-runtime-config-branding-settings.md
deleted file mode 100644
index 972d90f08c7..00000000000
--- a/.changeset/24766-runtime-config-branding-settings.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/cloud-connection': minor
----
-
-feat(cloud-connection): `GET /api/v1/runtime/config` serves the Branding settings' name, logo and colour, falling back to `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`
-
-Clause-②: no (the served `branding` block keeps its shape and no exported type changes; only where its values come from moves)
-
-`RuntimeConfigPlugin` reads the `branding` settings namespace on every request, so Setup → Branding changes the console and the sign-in page on their next load:
-
-- `branding.productName` ← `branding.workspace_name`
-- `branding.logoUrl` ← `branding.logo_url`
-- `branding.brandColor` ← `branding.accent_color`
-
-A value somebody set wins (saved in Setup, or pinned by the settings layer's own `OS_BRANDING_*` variable). A key nobody set falls back exactly as before: the host's constructor option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`, then the built-in default. `productShortName`, `pwaDescription` and `pwaThemeColor` follow the name and colour actually served, unless configured themselves. `brandColor` keeps its meaning: the console's browser and installed-app theme colour, beside each app's own `AppBranding` colours.
-
-The door reads the settings only when the settings service lists the `branding` namespace. `@objectstack/service-settings` withholds it under the `group` and `isolated` tenancy postures, so on a walled deployment no tenant data reaches this anonymous door and the deployment's `OS_*` branding is served, as before. A settings read that fails serves the deployment's branding and logs one warning; the door never answers 500 for it.
diff --git a/.changeset/24766-setup-branding-entry.md b/.changeset/24766-setup-branding-entry.md
deleted file mode 100644
index 809d9916ead..00000000000
--- a/.changeset/24766-setup-branding-entry.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/platform-objects': minor
----
-
-fix(platform-objects): `SETUP_NAV_CONTRIBUTIONS` no longer carries the Setup → Branding entry; `@objectstack/service-settings` contributes it with the `branding` namespace
-
-Clause-②: no (the entry moves to the package that serves its page: a composition that serves Setup → Branding still lists it, and one that does not no longer lists a page it cannot open)
-
-The `nav_settings_branding` entry (Setup → Configuration → Branding) was a static item of `SETUP_NAV_CONTRIBUTIONS`, listed whatever the settings service offered. From this release `@objectstack/service-settings` does not offer the `branding` namespace under the `group` and `isolated` tenancy postures, so a static entry would open a page the server refuses with `404 UNKNOWN_NAMESPACE`.
-
-`SettingsServicePlugin` (`@objectstack/service-settings`, same release) now contributes the entry from the same `kernel:ready` step that offers the namespace, and only then. On a single-organization deployment Setup still lists Branding, now at the end of the Configuration group's platform entries. Its translated labels are unchanged and still keyed `apps.setup.navigation.nav_settings_branding`.
diff --git a/.changeset/24767-settings-test-send-recipient.md b/.changeset/24767-settings-test-send-recipient.md
deleted file mode 100644
index 7e63ea7c421..00000000000
--- a/.changeset/24767-settings-test-send-recipient.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@objectstack/plugin-email': patch
-'@objectstack/service-sms': patch
----
-
-fix(plugin-email, service-sms): Setup's "Send test email" and "Send test SMS" send to the signed-in admin when the request names no recipient (#24767)
-
-Clause-②: no
-
-The Mail and SMS settings pages offer a Send test button and no recipient input, so the console's request carries no `to`. "Send test email" fell back to the From address — often a no-reply mailbox nobody reads — and with From empty it refused, asking for a "to" address the page has no field for. "Send test SMS" refused every press the same way.
-
-With no `to` in the request, the test now goes to the person who pressed the button: `mail/test` sends to the caller's own account email and `sms/test` to the caller's own phone number, each read from the caller's own `sys_user` row. A `to` in the request body still wins, as before. When the caller's account has no address or number, or the account cannot be read, the action refuses and says that nothing was sent. A phone number is on an account only where phone sign-in is enabled, so on other deployments "Send test SMS" still needs a `to` in the request body.
-
-`sms/test` no longer reports a send through the log-only transport (the namespace's default `None (log only)` provider) as a success. It answers `ok: false` with severity `warning`, as `mail/test` already does for its log transport. When the form names no delivering provider and the transport the deployment configured at boot carries the test, the success message names that transport instead of saying the message went "via log".
-
-**Unchanged.**
-
-- The request and result shapes of `POST /api/settings/mail/test` and `POST /api/settings/sms/test`: no key, route or setting is added.
-- Which provider and credentials a test uses: the saved values overlaid with the form's unsaved edits, as before.
-- Saving the Mail form still judges a required field only when the save writes it, or writes a key its visibility depends on.
diff --git a/.changeset/24772-primary-datasource-test-sync.md b/.changeset/24772-primary-datasource-test-sync.md
deleted file mode 100644
index 2005d30d959..00000000000
--- a/.changeset/24772-primary-datasource-test-sync.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/service-datasource': minor
----
-
-fix(service-datasource): Setup → Datasources: Test answers truthfully for the platform's primary datasource and for drivers that cannot introspect, and Sync on the primary datasource refuses with the true reason
-
-Clause-②: yes (widening: the published `ExternalDatasourceServiceConfig`, exported from the package entry, gains one optional member, the `liveDriver` callback; no status code, error code or accepted value moves on any route, and Test's answer keeps its existing keys)
-
-- **What was wrong.** The Datasources page listed the primary datasource (`default`) as connected, which is true. Its Test then answered `200 { ok: false }` and Sync answered `400`, both saying the datasource "has no registered driver to introspect". The primary datasource's driver is registered under the driver's own name, not under `default`, and both doors looked it up by `default`. A connected `memory` datasource also got `ok: false` from Test, because Test was an introspection round trip and that driver cannot introspect.
-- **Test, `POST /api/v1/datasources/:name/test`.** For the primary datasource, Test now finds the live driver the way the connection service's own teardown does and answers its health check: `{ ok: true, latencyMs }`. It does not list the platform database's tables. For a driver with no schema introspection (`memory`, `mongodb`), Test answers the health check too, `{ ok: true, latencyMs }` with no `tableCount`, instead of `ok: false`. A driver that fails its health check answers `ok: false`. Every other datasource is tested exactly as before (an introspection round trip with `tableCount`), and so is a datasource with no live driver.
-- **Sync on the primary datasource.** `GET …/remote-tables`, `POST …/object-draft`, the import (`POST …/external/tables/:remote/import`) and the catalog refresh still refuse `default`, with the same status and code as before (`400 EXTERNAL_DATASOURCE_ERROR`, or `EXTERNAL_IMPORT_ERROR` on the import). The message now gives the true reason: the primary datasource is the platform's managed storage, not a federation source, so there are no remote tables to list, draft or import from it. Its tables are never introspected.
-- **Unchanged.** Sync on every other datasource, including a managed runtime one, answers exactly as before.
-- **New, optional: `ExternalDatasourceServiceConfig.liveDriver`.** `(datasource) => driver | undefined` returns the live driver registered for a datasource, resolved by the primary datasource's naming rule. `ExternalDatasourceServicePlugin` wires it. Test reads it to reach the primary datasource and drivers without schema introspection.
-
-What to do: nothing, if the plugin builds the service. If you build `ExternalDatasourceService` yourself, pass `liveDriver` to get the new Test answers; without it, Test stays the introspection round trip, as before.
diff --git a/.changeset/24773-oauth-application-type.md b/.changeset/24773-oauth-application-type.md
deleted file mode 100644
index e52106467d2..00000000000
--- a/.changeset/24773-oauth-application-type.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): the Console's OAuth Application Type reaches the provider as `application_type`, so a Native client can register a loopback redirect URI (#24773)
-
-Clause-②: no — the declared type now takes effect under the OAuth provider's own redirect-URI rule, an authentication behaviour the maintainer ruled on; nothing published widens.
-
-`POST /api/v1/auth/sys-oauth-application/register` (the Setup → OAuth Applications form) forwarded the form's Application Type as `type`. The provider's `/oauth2/create-client` body declares `application_type` (`web` | `native`) and no `type`, so the value was dropped and every client was judged and stored as `web`. A `native` client therefore could not register a loopback redirect URI such as `http://localhost:4001/callback`, and its `sys_oauth_application.type` read `web`.
-
-- **Now.** `web` and `native` are forwarded as `application_type`, and the provider's own rule decides. A `native` client may use http only on exactly `localhost`, `127.0.0.1` or `[::1]` (any port), and is stored as `native`. A `web` client still needs an https URI on a non-loopback host.
-- **Refused.** Any other type, including the form's `user-agent-based` and `public`, has no `application_type` in the provider. The register route refuses it with `400 INVALID_REQUEST` and registers nothing (this release's breaking entry for the route). To use a loopback redirect URI, register the client as `native`.
-- **Unchanged.** No type at all is `web`. Refusals keep the codes and statuses they had: `400 OAUTH_REGISTER_FAILED`, with `error.details.upstreamError` `invalid_redirect_uri` when the redirect-URI rule refuses.
diff --git a/.changeset/24773-oauth-form-types.md b/.changeset/24773-oauth-form-types.md
deleted file mode 100644
index 9e5294cf0a4..00000000000
--- a/.changeset/24773-oauth-form-types.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): the OAuth application register form offers only Web and Native, and its Redirect URLs help states the rule for each (#24773)
-
-Clause-②: no (a form's offering: nothing on the server reads the action's param options, so this change moves no accept set or served shape; the register route's refusal of the other types is its own breaking entry in this release)
-
-Setup › OAuth Applications › Register offered four application types: Web, Native, User-agent based and Public. The OAuth provider judges only `web` and `native`, and the register route refuses any other type with `400 INVALID_REQUEST`. The form now offers Web and Native, and still defaults to Web.
-
-- **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port.
-- **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`.
-- **Unchanged.** A row written under an earlier provider line keeps its stored type, and the `public` column is unchanged; the stored column's options narrow to Web and Native in this release's `sys_oauth_application.type` entry.
-
-The zh-CN, ja-JP and es-ES translations carry the new help texts.
diff --git a/.changeset/24773-oauth-register-refusal-status.md b/.changeset/24773-oauth-register-refusal-status.md
deleted file mode 100644
index 2807da55eb4..00000000000
--- a/.changeset/24773-oauth-register-refusal-status.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): a refused OAuth application registration answers the status the refusal declared, not 500 (#24773)
-
-Clause-②: no
-
-`POST /api/v1/auth/sys-oauth-application/register` answered most refusals from better-auth as `500 OAUTH_REGISTER_FAILED`. better-auth raises each refusal as an `APIError` whose HTTP status is the number `statusCode` (`400`) and the name `status` (`'BAD_REQUEST'`). The route read only a numeric `status`, so a named refusal fell through to 500. Two examples are a redirect URI the provider refuses, such as `http://localhost:4001/callback` on a `web` client (`invalid_redirect_uri`), and a request with no session (`UNAUTHORIZED`).
-
-- **Now.** The route answers the refusal's own status: `400` for the redirect-URI refusal and `401` for the missing session. The code stays `OAUTH_REGISTER_FAILED`, and `error.details.upstreamError` still carries the provider's discriminator (`invalid_redirect_uri`).
-- **Unchanged.** A throw that declares no status, such as a storage fault while the client is persisted, still answers `500`. The body-schema refusal keeps answering `400`. Which redirect URIs a client may register is unchanged: a `web` client still needs an `https` URI on a non-loopback host.
diff --git a/.changeset/24773-oauth-type-refusal.md b/.changeset/24773-oauth-type-refusal.md
deleted file mode 100644
index f96ffc0a658..00000000000
--- a/.changeset/24773-oauth-type-refusal.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-'@objectstack/plugin-auth': minor
-'@objectstack/platform-objects': patch
----
-
-fix(plugin-auth)!: `POST /api/v1/auth/sys-oauth-application/register` refuses an application type the OAuth provider does not judge with `400 INVALID_REQUEST`, where it registered it as a `web` client
-
-Clause-②: no (narrowing) — the register door accepted any `type` value and registered every one other than `web` / `native` as a `web` client with a secret; it now refuses each of those values with 400, so it accepts exactly `web`, `native` and no type.
-
-
-
-**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-The OAuth provider (`@better-auth/oauth-provider`) judges two application types, `web` and `native`, and this door forwards the type the Setup → OAuth Applications form chose as the provider's `application_type`. Any other value has no provider counterpart. The door used to register such a request as a confidential `web` client, with a client secret and the web redirect-URI rule, which is a different kind of client from the one the request asked for. It now refuses the request instead.
-
-- **FROM:** a request whose `type` is present and is neither `web` nor `native` (`user-agent-based`, `public`, any other string including the empty string, or a value that is not a string) answered `200` and stored a `web` client, or was refused by the web rule (`OAUTH_REGISTER_FAILED`) for a loopback redirect URI.
-- **TO:** the same request answers `400` with `error.code: 'INVALID_REQUEST'`. The message names the value (its first 64 characters) and the two accepted types, and says to choose `native` for an `http://localhost`, `http://127.0.0.1` or `http://[::1]` redirect URI. The provider is not called, nothing is stored, and no warning is logged.
-- **Accepted:** `web` and `native` are forwarded as the provider's `application_type` (this release's Application Type entry), and the provider's own redirect-URI rule decides.
-- **Unchanged:** A request with no `type`, or with `type: null`, registers a `web` client, the provider's default. The other refusals keep their codes (their statuses are this release's refusal-status entry). Clients already registered are not touched.
-- **`@objectstack/platform-objects`:** only the code comment on the `create_oauth_application` action changes, to state this refusal; it ships in the package's JavaScript. The form offers only Web and Native (this release's register-form entry).
-- **The fix for an integrator:** send `type: 'web'` for a client whose redirect URIs are https on a host that is not a loopback address, or `type: 'native'` for a client that also redirects to `http://localhost`, `http://127.0.0.1` or `http://[::1]`. Omitting `type` registers `web`.
diff --git a/.changeset/24774-rotate-secret-cut-off-copy.md b/.changeset/24774-rotate-secret-cut-off-copy.md
deleted file mode 100644
index cebfe019c9a..00000000000
--- a/.changeset/24774-rotate-secret-cut-off-copy.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): Rotate Client Secret's result dialog states the immediate cut-off its confirmation states
-
-Clause-②: no (text only: no accept set, served shape or behaviour moves)
-
-- **Before.** On `sys_oauth_application`, the Rotate Client Secret confirmation said the previous secret "will stop working immediately". The result dialog then told the admin to "update every integration before the previous secret's grace period ends". Only the first is true.
-- **What rotation does.** `POST /api/v1/auth/oauth2/client/rotate-secret` is better-auth's own route. It overwrites the client's one stored secret, and client authentication checks a presented secret against that value only. No previous secret is kept, so there is no grace period: the old secret is refused from the moment the rotation completes.
-- **Now.** The result dialog reads "Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret." The zh-CN, ja-JP and es-ES translations of that sentence are updated to match. The confirmation, the dialog's title, acknowledgement and secret field are unchanged.
diff --git a/.changeset/24775-permission-set-assignment-person-title.md b/.changeset/24775-permission-set-assignment-person-title.md
deleted file mode 100644
index e935a57bffd..00000000000
--- a/.changeset/24775-permission-set-assignment-person-title.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id
-
-Clause-②: no (no accept set moves: the value a declared formula computes on read changes; no key, column, write path or authorization answer moves)
-
-- **Before.** `sys_user_permission_set`'s record title (`display_title`, the object's `nameField`) composed the stored user id, so Setup's list of a user's permission sets and the assignment's record page read like `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → crm_edit`.
-- **Now.** The title names the person: the user's `name`, or their `email` where the name is blank, then the set, as in `Alice Liddell → crm_edit`. The name is read one hop through `user_id`, under the reader's own permissions (the ADR-0079 addendum on one-hop formula titles). A reader who may not read that user, and a grant whose user no longer exists, get no title (`null`), never the user's name or email; a renderer then falls to the object's `titleFormat`, `{user_id} → {permission_set}`. No column is stored, and the title is still neither sortable nor searchable.
-- **Cost.** A read of `sys_user_permission_set` that computes its formulas (no field projection, or a projection naming `display_title`) now issues one batched `sys_user` read per page. The grant read every authenticated request makes is not one of them: it names the columns it decides on, so it computes no title, and the request's authorization prelude stays 8 reads in 4 round trips for a principal holding a direct grant.
diff --git a/.changeset/24782-advisory-unevaluable-log.md b/.changeset/24782-advisory-unevaluable-log.md
deleted file mode 100644
index 269b0267054..00000000000
--- a/.changeset/24782-advisory-unevaluable-log.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/objectql': patch
----
-
-fix(objectql): an advisory validation rule that cannot be evaluated logs one line saying the write proceeded, not two saying it was rejected (#24782)
-
-Clause-②: no (log text only: no response, status, code, outcome or export moves)
-
-A `severity: 'warning'` or `'info'` validation rule whose predicate cannot be evaluated never blocks the write. Only `error` rules fail closed (#4649). The write answers 2xx and the row persists. The server log said the opposite, twice. The predicate evaluator logged "— write rejected: a rule that cannot be evaluated fails closed, it is never skipped" before the rule's severity was consulted. Then the advisory per-write line repeated the unevaluable violation's own message, which also ends "— write rejected.".
-
-Such a rule now logs ONE line: `Validation rule 'NAME' (warning): the rule could not be evaluated (FAULT) — write proceeded: the rule is advisory, so it was not applied to this write`. This covers a `script` / `cross_field` condition, a `conditional`'s `when`, and the relationship-traversal refusal. On the seed / boot path the same text folds into that load's advisory summary line, which keeps its shape.
-
-Unchanged: an unevaluable `error` rule is refused with the same `400 VALIDATION_FAILED` body, and its log line still says the write was rejected and the rule fails closed. Which rules block is unchanged. An unevaluable rule still stays out of a write answer's `warnings`. `requiredWhen`, `readonlyWhen` and the option `visibleWhen` gate still refuse on every severity.
diff --git a/.changeset/24785-mention-recipients-read-gated.md b/.changeset/24785-mention-recipients-read-gated.md
deleted file mode 100644
index d9264d3f3e9..00000000000
--- a/.changeset/24785-mention-recipients-read-gated.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-audit': patch
----
-
-fix(plugin-audit): an @mention notification reaches only mentioned users who can read the comment's record
-
-Clause-②: no (narrows who a notification reaches; no key, route or payload shape is added)
-
-- **Recipients are judged by read access to the record.** The `sys_comment` mention writer now resolves each `mentions` entry to a user and asks, in that user's own envelope, whether they can read the record named by the comment's `thread_id`. The envelope comes from the resolver every request uses (`resolveUserAuthzGrants`). The user's organization claim is vetted under the tenancy posture in force (`vetOrganizationClaim`). The read goes through the evaluator every parent-derived read gate uses (`resolveReadableParentIds`). So the tenant wall, sharing, RLS and object permissions decide, as they do when that user opens the deep link. A user who cannot read the record gets no notification, and a `warn` line records their id and the reason (never the comment text).
-- **Email entries resolve to a user first.** An email-shaped mention is looked up to its user and then checked like an id. An email that matches no user notifies nobody. Entries that expand an audience (`role:`, `team:`, `owner_of:`) name no single person and are no longer mentions.
-- **Fail closed.** A comment whose thread names no record, a record that cannot be found, and a grant resolution that fails all withhold the notification. `installAuditWriters` takes the gate as `admitMentionRecipients`, and `AuditPlugin` wires it. A host that calls `installAuditWriters` without one emits no mention notifications and is told so once at `warn`.
diff --git a/.changeset/24796-list-view-binding-explanations.md b/.changeset/24796-list-view-binding-explanations.md
deleted file mode 100644
index 646f49a5b21..00000000000
--- a/.changeset/24796-list-view-binding-explanations.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/lint': patch
----
-
-docs(lint): `os explain` covers `list-view-object-unknown` and `list-view-object-outside-closure`, and the `packageScope` docblock says what a whole-collection miss now means
-
-Clause-②: no (no verdict, severity, accept set or served body moves: explanation text and comments only)
-
-- **`os explain list-view-object-outside-closure`** explains the package closure a write into a package is judged against (the package's own objects, its declared `dependencies`' objects, platform objects and provenance-less tenant objects), why a list view bound outside it is refused, and the remedy: declare the dependency, or bind the view inside the closure.
-- **`os explain list-view-object-unknown`** explains why a write that names no package can say an object exists nowhere (it is judged against the whole collection the deployment serves), why an empty collection is not judged, and why `os validate` / `os build` / `os lint` still skip the binding.
-- **`os explain list-view-field-unknown`** no longer says an unresolvable binding always skips the list view: it does on the CLI, and the runtime save door refuses it under one of the two ids above.
-- **`AuthoringRuleContext.packageScope`** (in the published `.d.ts`) no longer says an object missing from a whole snapshot "may come from another package": that is true only on the CLI. At the runtime door with no closure in force the snapshot is the whole served collection, and such a binding is refused `list-view-object-unknown`.
diff --git a/.changeset/24799-vendor-client-address-trust.md b/.changeset/24799-vendor-client-address-trust.md
deleted file mode 100644
index cfc6e60e4df..00000000000
--- a/.changeset/24799-vendor-client-address-trust.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): the vendored auth library records and keys on the client address the server established (#24799)
-
-Clause-②: no
-
-better-auth reads a request's client address with its own reader, and three things are keyed on what it reads: a session's `ip_address` (and the `login` / `logout` audit rows copied from it), the address on the `login_failed` / `account_locked` audit rows, and the key of better-auth's own per-address rate limiter. That reader did not follow `server.trustProxy`. It now follows the same rule as the `allowed_ip_ranges` allow-list and `last_login_ip`: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address. When neither is available, better-auth is handed no address.
-
-**For operators:**
-
-- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack. Without it, sessions and audit rows record the proxy's own address, and better-auth's per-address rate limit counts every client behind the proxy as one client.
-- With `server.trustProxy` declared, a forwarded chain (`client, proxy`) now resolves to its first entry. Before, better-auth read a multi-entry header as no address.
-- A request whose client address cannot be established records no address, and better-auth's rate limiter counts it in one shared bucket.
-
-**For hosts:** `AuthManager.handleRequest` now drops any `x-objectstack-client-address` request header a caller sends. The platform writes that header itself, from the address it established, and better-auth reads no other header for the client address. A programmatic `auth.api.*` call is handed whatever headers its caller passes. Route a client's request through `handleRequest` instead.
diff --git a/.changeset/24808-user-field-foreign-reference-warning.md b/.changeset/24808-user-field-foreign-reference-warning.md
deleted file mode 100644
index 5e599bf5735..00000000000
--- a/.changeset/24808-user-field-foreign-reference-warning.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/lint': minor
-'@objectstack/spec': patch
----
-
-feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
-
-Clause-②: yes (widening)
-
-- **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
-- **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '' }` (`Field.lookup('', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
-- **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
-- **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
-- **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
-- **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.
diff --git a/.changeset/24809-cors-doors-one-admission-rule.md b/.changeset/24809-cors-doors-one-admission-rule.md
deleted file mode 100644
index 65f6c434583..00000000000
--- a/.changeset/24809-cors-doors-one-admission-rule.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@objectstack/plugin-hono-server': minor
-'@objectstack/hono': minor
-'@objectstack/plugin-auth': minor
----
-
-fix(cors)!: every CORS door reads one admission rule; a loopback origin beside a list is admitted on a development boot only; an all-host pattern is refused with credentials, as `'*'` is
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** — an admission narrowing at three doors, shipped as `minor` under the launch-window convention for accept-set narrowings. The governing text is `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an explicit origin list. It finishes what the `HonoServerPlugin` default change that `18.0.0` shipped (#24738) began.
-
-- **One rule.** `@objectstack/plugin-hono-server` exports `resolveCorsAdmission`, and both transport doors read it: `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) and `@objectstack/hono`'s `createHonoApp`. One configuration now gets one answer at both. That one rule shared by two published doors is why the public surface grows: `resolveCorsAdmission` with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`, `isAllHostOriginPattern`, `OriginMatcherOptions`, and an optional second parameter on `createOriginMatcher`.
-- **`createHonoApp`, before.** With no origin list (no `cors.origin` option and no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests, and a configured `'*'` with credentials on was accepted the same way. **Now** it answers as `HonoServerPlugin` does: no list admits no cross-origin caller (a loopback origin excepted, on a development boot), and `'*'` with credentials on is refused when the app is created.
-- **`createHonoApp`'s `cors.origin` as one comma-separated string, before.** A `cors.origin` written as one comma-separated string of exact origins (`'https://a.example.com,https://b.example.com'`) admitted no origin. **Now** each listed origin is admitted, as `OS_CORS_ORIGIN` written the same way already was: a widening only toward what the operator listed, which the option's TSDoc already promised ("Comma-separated string or array").
-- **Loopback beside a list, before.** A configured list also admitted every loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port), in every mode, at both doors that used the pattern matcher. **Now** that happens on a development boot only (`NODE_ENV` exactly `development`, read through `isDevelopmentBoot` from `@objectstack/core`). Elsewhere a list admits what it names; a list that names `http://localhost:*` still admits it. One door gains on a development boot: `createHonoApp` with a list of exact origins never admitted a loopback origin, and now does there, as `HonoServerPlugin` already did. `matchOriginPattern` now matches the pattern and nothing else, and `createOriginMatcher` takes an optional `{ developmentBoot }`, read from `NODE_ENV` when the matcher is created if it is not passed, so a consumer that applies the matcher outside the Hono pipeline answers as the doors do on the same boot.
-- **All-host patterns, before.** Only a literal `'*'` was refused with credentials. **Now** a pattern whose host part is the bare `*` (`https://*`, `*://*`, `http://*:*`; `isAllHostOriginPattern`) is refused with credentials at both transport doors exactly as `'*'` is, and the message names the entry.
-- **Auth's trusted origins, before.** `@objectstack/plugin-auth` tested the whole `OS_CORS_ORIGIN` value against `'*'` and then split it, so a `'*'` entry among real origins, or an all-host pattern, entered better-auth's trusted origins. **Now** each entry is judged on its own, and an entry that admits every host never enters the trusted set.
-- **Unchanged.** Same-origin requests need no CORS answer. An exact origin or a pattern that names part of the host (`https://*.example.com`) is admitted as before, credentials included; the one exception is the comma-separated `cors.origin` string above, which now admits what it lists. `'*'` with credentials off is still served. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Headers, methods and max-age are unchanged. `OS_TRUSTED_ORIGINS` still admits nothing to CORS.
-
-**What an operator does.** A host composing `createHonoApp` for a cross-origin front-end names its origins, in `OS_CORS_ORIGIN` or the `cors.origin` option. A deployment that reached the server from a loopback origin outside development names that origin in its list. A list holding `'*'` or an all-host pattern while credentials are on either names its origins or sets `OS_CORS_CREDENTIALS=false`.
diff --git a/.changeset/24813-adopt-orphans-readonly-package.md b/.changeset/24813-adopt-orphans-readonly-package.md
deleted file mode 100644
index d4b960078f7..00000000000
--- a/.changeset/24813-adopt-orphans-readonly-package.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/runtime': minor
----
-
-fix(runtime)!: `POST /api/v1/packages/:id/adopt-orphans` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as the other package write doors already do
-
-ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and every other door that changes a package (`POST /packages` with `overwrite`, `PATCH /packages/:id`, `PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuses such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The orphan-adoption door never asked the rule, and the protocol's `reassignOrphanedMetadata` asks none of its own: it rebinds every package-less `sys_metadata` row to whatever id it is handed. So a `manage_metadata` holder's adoption into such a package answered `200` and bound the environment's loose items into it. The door now asks the same rule after the capability gate and the walled refusal, and before the protocol is resolved.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** `POST /api/v1/packages/:id/adopt-orphans` naming a read-only package answered `200` with `{ success, reassignedCount, reassigned, targetPackageId }` and rebound every package-less `sys_metadata` row to that package.
-- **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and rebinds nothing. Because the refusal comes before the protocol is resolved, a deployment whose protocol cannot adopt answers the same `422` for a read-only package where it answered `501`.
-- **Unchanged:** a caller without `manage_metadata` is still refused `403` first, and under an `isolated` or `group` tenancy posture a caller below the operator rung is still refused `403 PERMISSION_DENIED` first. A package an organization owns adopts as before, and an id nothing installed still reaches the protocol, which binds the orphans to it as before.
-- **The fix for an integrator:** a package the deployment ships is managed by the deployment, not over this API. Adopt the loose items into a package you own (create one with `POST /api/v1/packages` if none exists).
diff --git a/.changeset/24817-later-step-original-submitter.md b/.changeset/24817-later-step-original-submitter.md
deleted file mode 100644
index b0aeffddf71..00000000000
--- a/.changeset/24817-later-step-original-submitter.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/plugin-approvals': patch
----
-
-fix(plugin-approvals): every step of a multi-step approval names the person who submitted the run, not the previous step's approver
-
-Clause-②: no
-
-- **What was wrong.** This corrects the unreleased later-step submitter that PR #24527 introduced (an approval decision resumes its flow as the person who decided, `.changeset/24321-resume-as-decider.md`). Because the nodes after a decision now run as the decider, an approval step that opened after another step's decision recorded that decider as its `submitter_id`. On every later step the "I Submitted" list showed the request to the previous approver, recall and resubmit admitted the previous approver and refused the real submitter, and every notice meant for the submitter (`approval.returned`, `approval.request_info`, `approval.sla_breached`, `approval.approved`, `approval.rejected`) went to the previous approver.
-- **Now.** Every step of a run records the user who triggered the run as its `submitter_id`, and as the actor of the step's `submit` timeline entry, whoever decided the steps before it. That holds on both `runAs` postures, after any number of decisions by different people, and on a step that a decision node reaches by routing around another. A run whose trigger resolved no user (a schedule, a write that carried no user) opens its later steps with no submitter, as it opens its first one.
-- **Unchanged.** The decider still runs the nodes after their decision: their identity, permissions and attribution, as the resume-as-decider change ships them. The first step of every run, and every single-step approval, are unchanged. Compared with the last release nothing moves: later steps named the run's submitter there too, so recall and resubmit on a later step go back to the person they reached before.
-- **`ApprovalService.openNodeRequest`.** A caller that passes `submitterId: null` now opens the request with no submitter. Before, `null` fell back to the calling context's user, the same as leaving `submitterId` out, which still falls back.
diff --git a/.changeset/24818-trustproxy-consumers-describe.md b/.changeset/24818-trustproxy-consumers-describe.md
deleted file mode 100644
index e8191a95615..00000000000
--- a/.changeset/24818-trustproxy-consumers-describe.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-docs(spec): `server.trustProxy`'s description names both consumers of the client-address rule
-
-Clause-②: no
-
-The `.describe()` on `server.trustProxy` named only the inbound rate limiter. `objectstack serve` also passes the value to the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges, and whose login audit stamps as `last_login_ip`, the address that the same rule establishes (`@objectstack/plugin-auth/client-address`). The description now names both. No key, type, default or accepted value changes.
diff --git a/.changeset/24821-operator-no-org-insert-403.md b/.changeset/24821-operator-no-org-insert-403.md
deleted file mode 100644
index ffa7b54cbd6..00000000000
--- a/.changeset/24821-operator-no-org-insert-403.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): a platform operator's tenant-scoped insert from a session with no active organization is refused 403 naming the missing organization, as a member's is, instead of a 500
-
-Clause-②: no (no accept set moves: the insert was refused before and is refused now, with the ADR-0123 D2 answer in place of an internal error)
-
-- **Before.** Under `group` or `isolated`, a platform operator (`admin_full_access`) whose session had no active organization got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` for a create on a tenant object whose posture lets it cross the organization wall (`access.default: 'private'`). The operator crosses the wall there, so the wall let the insert through, and the engine then refused the row as a system write with no organization.
-- **After.** That create gets `403 PERMISSION_DENIED` with the message a member gets in the same state. The message names the missing active organization and tells the user to join or select one.
-- **Unchanged.** Nothing was written before and nothing is written now. The refusal judges the row as it is about to be stored, the same row the engine's own organization check reads, so it covers exactly the inserts that answered 500 and no insert that was admitted. These are as before:
- - a row a `beforeInsert` hook places in an organization;
- - the operator's reads and updates across organizations;
- - inserts from an operator with an active organization;
- - inserts into objects that opted out of tenancy;
- - system contexts (which still meet the engine's own refusal);
- - every `single` answer.
diff --git a/.changeset/24822-update-repoint-honoured.md b/.changeset/24822-update-repoint-honoured.md
deleted file mode 100644
index 9549fdb07f3..00000000000
--- a/.changeset/24822-update-repoint-honoured.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/plugin-security': patch
----
-
-fix(objectql,plugin-security): an update's re-point of `organization_id` that the Layer 0 wall admits is stored as sent, not silently dropped
-
-Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
-
-**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an update's `organization_id`. The Layer 0 write wall admitted C, on the payload and on the row about to be stored. Then the engine's static-`readonly` update strip took it, because the platform's injected tenant column is `readonly: true`, and the row stayed in A. The update answered 2xx, by id and by predicate alike, and the only signals were a `droppedFields` entry and a WARN. An object that declares its own writable `organization_id` stored the same re-point, so the app objects on the injected column were the inconsistent half. This is the update half of the insert fix shipped beside it.
-
-**What changed.**
-
-- **`@objectstack/objectql`.**
- - `ObjectQL.update` reads `PostHookWriteImageCheck.judgesOrganization` (reached as `OperationContext.postHookWriteImageCheck`) once per update, for a non-system caller, and hands that one value to every static-`readonly` pass: the pre-hook hide pass, the by-id strip, the predicate strip, and the stored view the `readonlyWhen` strips and the master-detail settlement read.
- - Where it is set, and the object's `organization_id` is the platform's injected column on an object the insert strip also judges, those passes leave the caller's re-point on the row. The wall then stores it as sent where it admits it and refuses it where it does not.
- - The decision is one helper, shared with `ObjectQL.insert`, so the two write verbs spare exactly the same objects.
- - The member's published doc now covers updates. No member is added.
-- **`@objectstack/plugin-security`.**
- - Step 3.7 sets `judgesOrganization` on the stored-image seam it composes for a single-payload update, only where it composes the wall's own parts in, as it already did for an insert.
- - It does not set the member on a business-`check`-only seam, on a crossing platform operator's update (which gets no wall seam), or on an update whose payload is an array (step 3.7 composes no seam for one).
- - The wall's admit/refuse set is unchanged: a re-point outside the caller's scope is still refused `403 PERMISSION_DENIED`, and so is an emptied one (`null` or `''`).
-
-**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip and the strict flag) refuse nothing they accepted before.
-
-- **The re-point is stored as sent.** Under `group`, an update naming another organization of the caller's membership set moves the row there, by id and by predicate. Under `isolated`, an update naming the active organization is stored as before.
-- **No longer reported.** Such a re-point is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
-- **`strictReadonlyWrites` stores it.** An in-process `engine.update(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
-- **`beforeUpdate` hooks see it.** On a walled update, `beforeUpdate` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
-- **A member with no active organization.** Under `group`, such a member re-points within its membership set the same way, and outside it is refused.
-- **An on-behalf-of update (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The re-point is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted re-point was taken and the row kept its organization.
-- **Outcomes that read the stored organization now follow the admitted value.** An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate, and a hook that reads `organization_id` now see the organization the row is moved to, not the one it used to keep. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, as it already was on an object that declares its own `organization_id`; before, the re-point was dropped and the write reported success.
-- **Only the platform's injected column on an app object is spared.** A `sys_` object, a platform-internal `managedBy` bucket (`engine-owned`, `append-only`, `better-auth`), and an object whose author declares its own `organization_id` with `readonly: true` keep the update strip: the value is still taken, reported, and refused under strict, exactly as before. The organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) declare a writable `organization_id` the strip never took, so their re-points keep meeting their own guards exactly as before.
-- **Where no wall judges the column, nothing changes.** The re-point is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, a platform operator crossing the wall on its own behalf, and `preserveAudit`, whose update-only exemption never reaches `organization_id`.
-- **The `update`-mode `validate()` preview is unchanged.** It runs no middleware, so it holds no seam: it still reports an admitted re-point as dropped while the write stores it.
diff --git a/.changeset/24822-wall-judged-organization-stored.md b/.changeset/24822-wall-judged-organization-stored.md
deleted file mode 100644
index 9a16d8929da..00000000000
--- a/.changeset/24822-wall-judged-organization-stored.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/plugin-security': patch
----
-
-fix(objectql,plugin-security): an insert's explicit `organization_id` that the Layer 0 wall judges is stored as sent, not replaced by the active organization
-
-Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
-
-**What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an insert's `organization_id`. The Layer 0 write wall admitted C. Then the engine's static-`readonly` strip took it, because the platform's injected tenant column is `readonly: true`, and the driver stamped A. The insert answered 2xx with the row in A, single and array alike. The only signals were a `droppedFields` entry and a WARN. Under `isolated`, a caller that named its own active organization saw the same strip and report, for a value that ended up stored anyway.
-
-**What changed.**
-
-- **`@objectstack/objectql`.**
- - `PostHookWriteImageCheck` (reached as `OperationContext.postHookWriteImageCheck`) gains an optional member, `judgesOrganization?: boolean`. An enforcement layer sets it when the seam's `evaluate` holds every inserted row's `organization_id` to the tenant wall.
- - `ObjectQL.insert` reads that member to decide. When it is set and the object's `organization_id` is the platform's injected tenant column, a non-system caller's explicit `organization_id` stays out of the pre-hook withhold and out of the static-`readonly` strip. The injected column is told apart by the registry's provenance reader; an authored column keeps the strip. The value is then stored as sent where the wall admits it, and refused by the wall where it does not.
- - When the member is absent or `false`, both passes run exactly as before. That covers a seam that carries only a business `check`, a stack without an enforcement layer, and `validate()`, which runs no middleware.
-- **`@objectstack/plugin-security`.**
- - Step 3.7 sets the member only where it composes the wall's own parts into an insert's stored-image seam.
- - It does not set the member on a business-`check`-only seam, on a crossing platform operator's seam, or on any update.
- - The wall's admit/refuse set is unchanged: an organization outside the caller's scope is still refused `403 PERMISSION_DENIED`.
-
-**What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and nothing accepted before is refused now.
-
-- **The optional member.** `PostHookWriteImageCheck.judgesOrganization` (`OperationContext.postHookWriteImageCheck`) is new published surface on `@objectstack/objectql`.
-- **The value is stored as sent.**
- - Under `group`, an insert naming an organization of the caller's membership set is stored there.
- - Under `isolated`, an insert naming the active organization is stored as before.
-- **No longer reported.** Such an organization is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
-- **`strictReadonlyWrites` stores it.** An in-process `engine.insert(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
-- **`beforeInsert` hooks see it.** On a walled insert, `beforeInsert` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
-- **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall.
-- **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored.
-- **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value.
-- **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`).
-- **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf.
diff --git a/.changeset/24827-migrate-apply-birth-marker.md b/.changeset/24827-migrate-apply-birth-marker.md
deleted file mode 100644
index 05109dc5f8e..00000000000
--- a/.changeset/24827-migrate-apply-birth-marker.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/cli': minor
----
-
-fix(cli): `os migrate apply` on an empty database writes the organization-ownership birth marker, so the fresh install's first `os serve` starts
-
-Clause-②: yes
-
-`os serve` (and so `os dev` and `os start`) serves a database this release created itself, recognised by the birth marker in `sys_migration` (row `adr-0131-organization-ownership`), or one the ownership ceremony certified. `os migrate apply` holds its schema DDL back until the operator confirms, so its boot saw no table created and wrote no marker, and the tables it then created carried none. A deploy script that ran `os migrate apply` before the first `os serve` was refused as a database "17.x or an 18.0 pre-release created", and sent through the upgrade ceremony.
-
-- **Now.** When `os migrate apply` creates the database from empty, it writes the birth marker right after it creates the tables, and the first `os serve` starts. The log line is the one a serving boot prints: `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, …)`.
-- **Never on a database that was not empty.** The decision is the serving boot's own: the database counts as created from empty only when none of the tables this release declares existed before the apply. A database an earlier release created gains no marker from `os migrate apply`, even when the apply creates a table in it, and is still refused until the ceremony runs: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`.
-- **A failed write fails the command.** If the tables are created and the marker cannot be written, `os migrate apply` exits `1` and names the ceremony, which certifies such a database.
-- **Unchanged.** A database that already carries a marker keeps it as it is. `os migrate plan` writes nothing. On a Turso remote (`libsql://`) primary, `os migrate apply` creates no tables (its schema sync is skipped there), so the first serving boot creates them and writes the marker, as before.
diff --git a/.changeset/24833-dev-recover-line-names-no-origin.md b/.changeset/24833-dev-recover-line-names-no-origin.md
deleted file mode 100644
index 603f8bd799d..00000000000
--- a/.changeset/24833-dev-recover-line-names-no-origin.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/cli': patch
----
-
-fix(cli): `os dev`'s in-place upgrade line no longer says every refused database is "still in the v17 organization-ownership shape"
-
-Clause-②: no (printed text of `os dev` only: no exit code, refusal, recovered boot or IPC message moves)
-
-When the organization-ownership boot guard refuses the server `os dev` starts, `os dev` copies the development database and upgrades it in place. The line it printed first said the database was "still in the v17 organization-ownership shape", for every refusal. That was wrong for a database an 18.0 pre-release created, and for every refusal that is not about the database's origin at all: a marker that is unverified, unreadable or from an older shape generation, or a read or write the guard could not make.
-
-The line now names no origin. It points at the guard's own refusal, printed just above it, which says what the guard found, and it says that `os dev` upgrades the database in place, keeping a copy first. The verdict line printed after the upgrade still gives the copy's path. Which databases `os dev` upgrades, how it upgrades them, and what a bare `os serve` prints are unchanged.
diff --git a/.changeset/24835-driver-update-leaves-caller-data.md b/.changeset/24835-driver-update-leaves-caller-data.md
deleted file mode 100644
index 5fa54b11bd5..00000000000
--- a/.changeset/24835-driver-update-leaves-caller-data.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/driver-sql': patch
----
-
-fix(driver-sql): `update()` no longer writes its `updated_at` stamp into the caller's `data`, so webhook deliveries for record updates are stored again on PostgreSQL and MySQL
-
-Clause-②: no
-
-- **What was lost.** On PostgreSQL and MySQL, every webhook delivery for a record update was lost. `SqlDriver.update()` assigned its `updated_at` stamp onto the patch object the caller passed in. ObjectQL publishes that same object as the update event's `changes`. On those two dialects the stamp is a knex `Raw` (`CURRENT_TIMESTAMP`) that holds the database client, its pool and a timer. The webhook outbox could not serialise the event (`Converting circular structure to JSON`), so no `sys_http_delivery` row was written. The record write still answered 200, and the webhook still read active. Every other reader of the update event's `changes`, and of the patch after the driver call, saw the same `Raw`. On SQLite the stamp is an ISO string, so the delivery was stored, but the caller's patch and the event's `changes` still carried an `updated_at` the caller never wrote.
-- **What changed.** `update()`, including its rotated-table path, stamps `updated_at` into a fresh object, as `updateMany()` already did. The caller's `data` is left exactly as it was passed.
-- **Unchanged.** The stored `updated_at` value on every dialect, `preserveAudit` imports, tables without an `updated_at` column, `upsert()` (it already stamps a copy of its input), and every export and option.
diff --git a/.changeset/24835-webhook-lost-delivery-error.md b/.changeset/24835-webhook-lost-delivery-error.md
deleted file mode 100644
index 0cc0b163443..00000000000
--- a/.changeset/24835-webhook-lost-delivery-error.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-webhooks': patch
----
-
-fix(plugin-webhooks): a webhook delivery the outbox could not record is now reported at `error`, once per webhook, with what was lost and how to fix it
-
-Clause-②: no
-
-- **What was wrong.** When the `sys_http_delivery` write for a webhook event failed, the auto-enqueuer logged one bare `WARN [webhook-auto-enqueuer] enqueue failed` line per event. The event was lost: it is never sent, retried or redeliverable, because no row exists. The record write that produced the event had already answered 200, and the webhook still read active in Setup, so nothing else looked wrong. On PostgreSQL every record-update delivery was lost this way.
-- **What changed.** The first lost delivery for a webhook is logged at `error`. The line names the webhook, says the event is lost and why nothing else shows it, and says where the cause is (the record's meta) and what to restore. Later lost events for the same webhook are logged at `debug`, each with its `eventId`. The `error` is said again only after a delivery for that webhook was recorded in between. A logger without `error` still receives the line at `warn`. Bulk (`data.records.*`) deliveries are reported the same way.
-- **Unchanged.** Successful deliveries, the delivery rows, the parked-subscription path and every export. This change does not fix the PostgreSQL loss itself: that cause is upstream of the webhook plugin and is fixed separately.
diff --git a/.changeset/24844-google-env-credentials-save.md b/.changeset/24844-google-env-credentials-save.md
deleted file mode 100644
index 081438593c1..00000000000
--- a/.changeset/24844-google-env-credentials-save.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/service-settings': patch
----
-
-fix(service-settings): Setup → Authentication saves "Enable Google login" ON when the Google credentials come from the server (#24844)
-
-Clause-②: no
-
-On a deployment that sets `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and leaves the two credential fields empty, saving `auth.google_enabled: true` was refused with `SETTINGS_VALIDATION` (`google_client_id` / `google_client_secret`: `required`). An admin who switched Google off on the page could not switch it back on there, although the server held valid credentials. The settings service reads only `OS_*` names, so it cannot see that pair.
-
-`auth.google_client_id` and `auth.google_client_secret` are no longer `required`. Google sign-in needs the two credentials to resolve, saved on the page or set on the server; it never needed them stored. The `google_enabled` help already says so: Google is offered once both resolve, and the server's pair wins over saved values.
-
-Who may sign in, and which providers the login page offers, do not change for any deployment. Saving the toggle ON while neither pair resolves is now accepted; the login page then shows no Google button, as it does with the toggle left at its default.
diff --git a/.changeset/24863-approval-labels-reader-locale.md b/.changeset/24863-approval-labels-reader-locale.md
deleted file mode 100644
index 5f1e604c205..00000000000
--- a/.changeset/24863-approval-labels-reader-locale.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/plugin-approvals': patch
----
-
-fix(plugin-approvals): the approvals inbox, the record's approval panel and the request page name the flow, the step and the record's object in the reader's language
-
-Clause-②: no
-
-- **What was wrong.** An approval request copies the flow's label and the Approval node's label when it opens (`process_label`, `current_step_label`), and the approvals door served that copy to every reader. A user whose locale is zh-CN, ja-JP or es-ES saw the authoring language, although the app's language pack carried `flows..label` and `flows..approvals..label` for those strings. The inbox row's `object_label` was the object's untranslated schema label.
-- **Now.** `GET /api/v1/approvals/requests` (the inbox and the record's approval panel), `GET /api/v1/approvals/requests/:id` and the request a decision answers with serve `process_label`, `step_label` / `current_step_label` and `object_label` in the reader's locale (`Accept-Language`, else the `localization` settings). Each is read from the deployment's `i18n` service: `flows..label`, `flows..approvals..label` and `objects..label`. A bare `zh` reaches a `zh-CN` pack. The request get's step progress (`flow_steps`) names each step the same way.
-- **Fallback.** A label the pack does not carry, a locale no pack holds, a read without a locale and a stack without an `i18n` service all get the label the request stored, exactly as before. An `en` reader of an app authored in English sees no change.
-- **Unchanged.** The stored `process_label` and `current_step_label` columns keep the authored text they were written with: nothing is migrated or rewritten, and the generic data door, the list views and the one-tap action page still show the stored text. Who may read a request is unchanged. The `approval.requested` notification body is still English.
-- **`ApprovalServiceOptions.i18nServiceSource`.** A new optional option: a lazy reader of the `i18n` service. `ApprovalsServicePlugin` wires it from the kernel's `i18n` service. A host that constructs `ApprovalService` by hand and leaves it out keeps the stored labels.
diff --git a/.changeset/24865-auth-sms-template-organization.md b/.changeset/24865-auth-sms-template-organization.md
deleted file mode 100644
index 88674da6529..00000000000
--- a/.changeset/24865-auth-sms-template-organization.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): the phone OTP and invitation SMS read their template row in the organization the send resolves to
-
-Clause-②: no
-
-`sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The phone OTP and invitation SMS are deployment-level sends whose callbacks carry no organization, so their template row is now read in the organization that act resolves to, the tenancy service's `defaultOrgId()` (ADR-0093 D3), the same answer the membership reconciler binds a new user to:
-
-- **`single`:** the Default Organization's `sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, sms, locale)` decides the text, as an operator authored it in Setup.
-- **`isolated` and `group`:** no organization is resolved, so no template row is read and the built-in text is sent. No organization's row decides the text a user receives.
-- **No tenancy service, or a failed resolution:** the built-in text. A template outage still never blocks a send.
-- **Unchanged:** the locale ladder (the recipient's stored locale, then the deployment default, then `en`), how a deactivated or blank row passes its rung on, the built-in texts themselves, and the rendered holes.
diff --git a/.changeset/24865-template-recipient-organization.md b/.changeset/24865-template-recipient-organization.md
deleted file mode 100644
index 296f9aa9251..00000000000
--- a/.changeset/24865-template-recipient-organization.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/service-messaging': minor
----
-
-fix(service-messaging): a notification template is resolved in the recipient's organization
-
-Clause-②: yes (widening) — `NotificationTemplateStore.load` (exported from the package's `.` entry) gains an optional fourth parameter, `organizationId`.
-
-`sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The email and SMS channels now render the template row of the delivery's own organization: `NotificationTemplateStore.load(topic, channel, locale, organizationId)` keys its read by the organization the delivery names (`Notification.organizationId`, the organization the notification event row carries), the same way the preference resolver scopes its rows.
-
-- **An organization with no row of its own** for a `(topic, channel, locale)` renders the notification's own title and body, the documented no-template fallback.
-- **The new parameter:** `NotificationTemplateStore.load(topic, channel, locale, organizationId?)`. A call that names no organization reads no row and answers `null`, so the channel renders that same fallback. Every delivery the messaging service fans out names its organization (the event row takes it from the producer or from the engine's system-write rule, and an emit with neither is refused), so this changes nothing a composed deployment renders. A direct caller of `NotificationTemplateStore.load` passes the recipient's organization as the fourth argument.
-- **Unchanged:** the locale ladder (the recipient's locale, its language, then `en`), the `is_active` filter, how a row renders, and the best-effort answer on a failed lookup.
diff --git a/.changeset/24877-bridge-transport.md b/.changeset/24877-bridge-transport.md
deleted file mode 100644
index dfd0f4ba66d..00000000000
--- a/.changeset/24877-bridge-transport.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/plugin-auth': patch
----
-
-fix(plugin-auth): every in-process auth bridge judges the request it re-dispatches on the same client-address facts as the request it serves (#24877)
-
-Clause-②: no
-
-Eight auth routes answer by re-dispatching a request to better-auth in-process: `POST /api/v1/auth/admin/remove-user`, `/admin/has-permission`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/send-verification-email` and `/sys-session/revoke`. `AuthManager.handleRequest` establishes the re-dispatched request's client address by the client-address trust rule: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`, and otherwise the address is the connection's peer address. Each route handed `handleRequest` the request alone, without the connection it arrived on. Six of them also rebuild the request before re-dispatching it, and the rebuilt request did not carry the forwarding headers.
-
-- **Now.** Each route hands `handleRequest` the connection it was called on, as the main auth mount does. Each rebuilt request carries the incoming request's `X-Forwarded-For` and `X-Real-IP`. Without a declared trusted proxy, the re-dispatched request is judged on the connection's peer address, whatever those headers say. With `server.trustProxy` declared, it is judged on the forwarded client address, the same address as the request the route serves.
-- **Unchanged.** What each route accepts or refuses, and every answer it gives. `resolveClientAddress` decides exactly as before. The main auth mount, and the `allowed_ip_ranges` allow-list in front of every auth route, already judged on the connection.
diff --git a/.changeset/24882-settings-format-refusal.md b/.changeset/24882-settings-format-refusal.md
deleted file mode 100644
index 3692eb97bf7..00000000000
--- a/.changeset/24882-settings-format-refusal.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-'@objectstack/service-settings': minor
----
-
-fix(service-settings)!: a settings value that misses the format its `email`, `url`, `phone` or `color` type declares is refused, both when it is saved and when it arrives as an `OS_*` override (#24882)
-
-ADR-0007 declares a format for four specifier types: `email`, `url` and `phone` take "format validation", and `color` takes a hex string. Until now nothing enforced it. `PUT /api/settings/branding` stored a Logo URL of `not a url` and answered `200`, and an `OS_*` override was served whatever it held. Both doors now judge the format through one shared check, after every other declared constraint, and refuse a value that misses it.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (the settings doors' accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-The formats are the platform's existing grammars for the same words:
-
-- `email`: an address with a dotted domain, the grammar the mail transport sends with. `no-reply@localhost` misses it.
-- `url`: `scheme://…`, a root-relative `/path`, a `data:` URI or a `blob:` URL. A relative logo path that the branding reader serves is accepted.
-- `phone`: digits, spaces and `+ ( ) - .`, at least five characters.
-- `color`: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
-
-Empty values and non-string values are not judged. Surrounding whitespace is ignored, because the readers trim it before use.
-
-What changes:
-
-- **Saving, FROM:** a save naming a value that misses its key's format answered `200` and stored the value as written.
-- **Saving, TO:** the save answers `400` with `error.code: 'SETTINGS_VALIDATION'` and one entry per such key in `error.details.fields`, carrying `code` `invalid_email`, `invalid_url`, `invalid_phone` or `invalid_format` (for `color`), and `constraint.type`. Nothing in the save is stored. The answer names the expected format and never echoes the value.
-- **Environment override, FROM:** a malformed `OS_*` override was in force: reads served it with source `env`, and it locked the key against saves.
-- **Environment override, TO:** a malformed `OS_*` override is no longer in force. The server logs one `error` line per variable and value, naming the variable, the setting, the type and the expected format, never the value. Reads serve the next layer instead: the stored value when one exists, otherwise the manifest default, with that layer as the source. The key is no longer locked, so it can be saved through Setup.
-- **Unchanged:** a well-formed value saves, and a well-formed override is in force and locks the key. A value that breaks another declared constraint (a length bound, a pattern) is still refused for that constraint. A value stored before this release is still served by every read, and it does not block a save of the namespace's other keys; only a save that writes that key, or a key its `visible` condition reads, judges it. An all-null save (reset) still clears it.
-- **What to do:** fix the value. For an `OS_*` override, set the variable to a well-formed value or unset it and configure the setting in Setup. For a stored value, save a well-formed value for the key, or clear it.
diff --git a/.changeset/24896-prelude-grant-read-projection.md b/.changeset/24896-prelude-grant-read-projection.md
deleted file mode 100644
index efe454f8502..00000000000
--- a/.changeset/24896-prelude-grant-read-projection.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/core': patch
----
-
-fix(core): the authorization prelude's grant read names the columns it decides on, so no formula on `sys_user_permission_set` is computed in every request a grant holder makes
-
-Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves)
-
-`resolveAuthzContext` reads the principal's `sys_user_permission_set` grants on every authenticated request. That read named no columns, so the engine computed every formula field the grant table declares for it, the record title included, though the resolver reads none of them. A title that reads through a lookup (the person's name, one hop through `user_id`) would have cost each request by a principal holding a direct grant one more `sys_user` read and one more round trip: 9 reads in 5 round trips instead of 8 in 4. The read now names exactly the four columns the resolver decides on: `permission_set`, `organization_id`, `valid_from` and `valid_until`. The resolved authorization context is unchanged for every grant state (active, expired, not yet valid, organization-owned, deployment-wide, and the Default Organization's `admin_full_access` grant), and a grant holder's prelude stays at 8 reads in 4 round trips whatever display formula the grant table declares.
diff --git a/.changeset/24899-archived-mail-bodies.md b/.changeset/24899-archived-mail-bodies.md
deleted file mode 100644
index e5925aa6f8d..00000000000
--- a/.changeset/24899-archived-mail-bodies.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/cli': minor
-'@objectstack/plugin-audit': minor
----
-
-feat(cli,plugin-audit): `os migrate audit-mail-bodies` also masks the mail bodies in the audit copies already moved to the `archive` datasource
-
-Clause-②: yes (widening)
-
-**Re-run `os migrate audit-mail-bodies --apply` on a deployment with an archive datasource.** The 18.0.0 command masked the hot audit tables only.
-
-- **What was left in clear.** `sys_audit_log` declares `lifecycle.archive: { after: '90d', to: 'archive', keep: '7y' }`. Past the hot window, the lifecycle Archiver copies its rows to the datasource named `archive` as they are, then deletes them hot. A `sys_email` / `sys_identity_email` copy archived before the audit writer masked the mail body still holds `body_text` / `body_html` in clear there, password-reset, verification, magic-link and invitation links included, for as long as the archive keeps it. The engine never routes `sys_audit_log` to the archive, so the hot run could not reach those rows.
-- **What changed.** After the hot run, the command reads the archive target each copy table declares (`sys_audit_log` only; `sys_activity` declares none) and runs the same mask, in the same dry-run or `--apply` mode, through that datasource's driver. Each archived row keeps every other column, `updated_at` included. The run is idempotent and its report is counts only. A hot copy masked first is archived masked, so one run covers both the copies already archived and the ones still to be.
-- **Which archives it reaches.** The command now connects the managed datasources the stack config declares, through the same connect service `os serve` uses. An archive datasource is declared with `autoConnect: true`, since nothing routes objects to it. It is NOT examined, and the run reports `not-connected` and exits `1` rather than reporting a clean archive, in two cases. In the first, the archive is declared but this boot did not connect it; the entry carries the connect status and reason. In the second, the archive is a runtime datasource created in Setup → Datasources, which `os serve` connects and this command does not; declare it in the config's `datasources` and re-run.
-- **What `none` means.** No datasource of that name is declared and connected on this boot, none was attempted, and no active runtime datasource row names one. That is all it checks. An archive a host registers in code outside the stack config is not reached and reads `none`. To mask it, declare it in the stack config's `datasources` with `autoConnect: true`.
-- **The command's boot can now fail on a declared datasource.** A bound, mapped or boot-critical declared datasource that cannot connect now fails the command's boot with exit `1`, as it fails `os serve`'s. `OS_ALLOW_DRIVER_CONNECT_FAILURE` is the same escape.
-- **Run it with the server stopped**, or re-run it once and read 0 to mask in both halves. A live server's Archiver can move a row between the hot run and the archive run.
-- **New in the `--json` document:** an `archive` array, one entry per declared target: `{ datasource, objects, status, report?, connection? }`, with `status` one of `examined`, `none`, `not-connected` or `unknown`. The `report` key and its shape are unchanged.
-- **New exports from `@objectstack/plugin-audit`:** `migrateArchivedMailLogBodyCopies(driver, logger, options)`, the runner over a datasource's driver, and an `objects` option on both runners, which limits a run to the named copy tables. Types `MailLogBodyArchiveDriver`, `MailLogBodyCopyObject` and `MailLogBodyMigrationOptions`.
diff --git a/.changeset/24900-webhook-label-description-live.md b/.changeset/24900-webhook-label-description-live.md
deleted file mode 100644
index 0a3873c13c0..00000000000
--- a/.changeset/24900-webhook-label-description-live.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-fix(spec): `os lint` no longer tells authors to remove a declared webhook's `label` or `description`
-
-Clause-②: no (a liveness-ledger re-grade: no schema, accept set, served body or error code moves; only the advisory `liveness-dead-property` warning on these two keys stops)
-
-The shipped liveness ledger (`liveness/webhook.json`) graded a declared webhook's `label` and `description` `dead` once the boot seeder that copied them into a `sys_webhook` row retired, and the advisory lint warned "Remove it" on every webhook that authored them. They are read: the Studio metadata list and quick-find show both keys, read off `GET /api/v1/meta/webhook`. Both rows are now `live`, so a declared webhook may keep its `label` and `description` and no warning is printed for them. Nothing else changes: the dispatcher still delivers on the same props as before.
diff --git a/.changeset/24909-missing-driver-client-remedy.md b/.changeset/24909-missing-driver-client-remedy.md
deleted file mode 100644
index 6a25bc3b9f2..00000000000
--- a/.changeset/24909-missing-driver-client-remedy.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/service-datasource': patch
----
-
-fix(service-datasource): a datasource whose database client is not installed (`pg`, `mysql2`, `better-sqlite3`) is refused with the install of that client, not with "Run `pnpm install && pnpm build`"
-
-Clause-②: no
-
-- **What was wrong.** When a boot-critical datasource could not connect because a module could not be resolved, the refusal always ended on the unbuilt-workspace remedy: "The driver package could not be LOADED at all … That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for a monorepo checkout whose driver build output is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client: `pg` and `mysql2` are optional peers of `@objectstack/driver-sql` (and `better-sqlite3` an optional dependency) that the project installs itself, so neither command adds them, and a reader who followed the advice was refused again with the same text.
-- **Now.** When the module that could not be resolved is one of those three clients (knex reports it as `Cannot find module 'pg'`), the refusal ends on the install of that client instead: `npm install pg` (with the `pnpm add` and `yarn add` spellings beside it), and no build step. It names no environment variable.
-- **Unchanged.** A driver package whose build output is missing, or a required `@objectstack/…` driver package that is not installed (any but the optional `@objectstack/driver-turso`, `@objectstack/driver-mongodb` and `@objectstack/driver-sqlite-wasm`), still ends on `pnpm install && pnpm build`. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change: only the closing sentence of the refusal is different.
-
-What to do: install the client the refusal names next to the server that opens the datasource, then start again.
diff --git a/.changeset/24910-declared-webhook-undeliverable.md b/.changeset/24910-declared-webhook-undeliverable.md
deleted file mode 100644
index 8061a18c6e1..00000000000
--- a/.changeset/24910-declared-webhook-undeliverable.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@objectstack/plugin-webhooks': patch
----
-
-fix(plugin-webhooks): a declared webhook that the deployment can never deliver is reported once at boot, at `error`, with the consequence and the fix
-
-Clause-②: no
-
-The webhook auto-enqueuer reads record events only from the realtime service. A stack with `requires: ['webhooks']` and no `'realtime'` therefore never delivers a declared webhook.
-
-- **Before.**
- - The boot logged one `WARN [webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available`. The line named no webhook, gave no consequence and gave no fix.
- - Every record write still answered 200, the declaration still read active, and no delivery row was ever written.
-- **After.** When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before.
-- **Unchanged.**
- - Every deployment whose auto-enqueuer runs.
- - A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line. It turned the enqueuer off itself.
- - `os validate` and `os build`: they still accept a stack that declares webhooks without these tokens.
- - The activation door (`POST /api/v1/security/_activation/webhook/NAME`): it answers exactly as before.
-- **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`.
diff --git a/.changeset/24915-approval-step-translation-key.md b/.changeset/24915-approval-step-translation-key.md
deleted file mode 100644
index 01b92a04d31..00000000000
--- a/.changeset/24915-approval-step-translation-key.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/lint': minor
----
-
-feat(spec): the `flows` translation face gains `approvals..label`, the step label of an `approval` node
-
-Clause-②: yes (widening)
-
-- **Before.** `TranslationDataSchema.flows.` carried `label`, `successMessage`, `errorMessage`, `screens.` (screen nodes) and `refusals.` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens..title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen.
-- **Now.** `flows..approvals..label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group.
-- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens..title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals..label` instead of being told only that the node is not a screen.
-- **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands.
-- **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.
diff --git a/.changeset/24923-sys-notification-subscription-retired.md b/.changeset/24923-sys-notification-subscription-retired.md
deleted file mode 100644
index 434690037bd..00000000000
--- a/.changeset/24923-sys-notification-subscription-retired.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@objectstack/service-messaging': minor
-'@objectstack/spec': minor
-'@objectstack/cli': minor
-'@objectstack/platform-objects': patch
----
-
-feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing.
-
-ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match.
-
-**What moves for consumers, FROM → TO.**
-
-- **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it.
-- **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles.
-- **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged.
-- **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.
-
-**Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`.
diff --git a/.changeset/24924-template-channel-email-sms.md b/.changeset/24924-template-channel-email-sms.md
deleted file mode 100644
index f2d1037f3d9..00000000000
--- a/.changeset/24924-template-channel-email-sms.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/service-messaging': patch
----
-
-fix(service-messaging): the `sys_notification_template` copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does
-
-A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so.
-
-This entry first added a `channel_renders_template` warning for such a write; in the same release #24983's refusal superseded it, so a write naming another channel is refused rather than warned about, as that entry describes. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does.
-
-Clause-②: no — this entry now describes copy only (the object's description, its empty state and the field help); the narrowing of what a write may send is #24983's, declared in its own entry.
-
-- **Unchanged.** Nothing this entry describes changes which writes are accepted. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged.
-- **What to do.** For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. A row for another channel was never sent, so delete it if you no longer want it listed.
diff --git a/.changeset/24925-plan-names-ownership-ceremony.md b/.changeset/24925-plan-names-ownership-ceremony.md
deleted file mode 100644
index 63de7d91fee..00000000000
--- a/.changeset/24925-plan-names-ownership-ceremony.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/cli': patch
----
-
-fix(cli): `os migrate plan` names the organization-ownership ceremony, not `--allow-destructive`, for the `organization_id` drops `os migrate apply` refuses (#24925)
-
-Clause-②: no
-
-On a database the organization-ownership ceremony has not verified, such as a 17.x one before the ceremony, `os migrate apply --allow-destructive` refuses every run that would drop an `organization_id` column, and drops nothing. No flag stands that refusal down. `os migrate plan` still listed each of those columns as `"os migrate apply --allow-destructive" to drop it.`, closed with `(add --allow-destructive for drops / tightenings)`, and did not mention the ceremony.
-
-- **Now.** Each such drop is still listed and counted as destructive drift. Its line names the ceremony as its remedy: `… (orphaned) — a drop the organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).`
-- **The footer.** It offers `--allow-destructive` for nothing while such a drop is listed, because `apply` refuses the whole run. It still offers `os migrate apply` when there is other work that command performs without the flag. Then it names the ceremony's two commands: `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`. It also counts the other destructive changes that wait for the ceremony.
-- **Unchanged.** On a database whose ceremony has verified, or one with no `organization_id` drop, the plan reads as before, byte for byte, and on the second it reads no marker. The `--json` document, the exit code and `os migrate apply` are unchanged.
-
-**What to do.** Nothing. On a 17.x database, follow the plan's footer: run the ceremony, then re-run `os migrate plan`.
diff --git a/.changeset/24936-create-user-dialog-copy.md b/.changeset/24936-create-user-dialog-copy.md
deleted file mode 100644
index 487793f350c..00000000000
--- a/.changeset/24936-create-user-dialog-copy.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): the Create User result dialog no longer tells an admin who typed the password to copy a temporary one
-
-Clause-②: no (text only: the create-user result dialog's sentence and button; no param, response, status, outcome or export moves)
-
-`sys_user.create_user`'s result dialog said "Copy the temporary password now — it is shown only once and never stored." with an "I have saved this password" button, in both outcomes. The endpoint returns `temporaryPassword` only when it generated one, so after a typed password the dialog showed the email alone and asked the admin to save a password it never showed. The dialog now reads "If a temporary password was generated, copy it now — it is shown only once and never stored." with a "Done" button — the same sentence and button as `set_user_password`, true for a typed and for a generated password. The zh-CN, ja-JP and es-ES bundles carry the same change, reusing `set_user_password`'s existing translations.
diff --git a/.changeset/24937-optional-driver-package-remedy.md b/.changeset/24937-optional-driver-package-remedy.md
deleted file mode 100644
index 05f934eeba4..00000000000
--- a/.changeset/24937-optional-driver-package-remedy.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/service-datasource': patch
----
-
-fix(service-datasource): a datasource whose optional driver package is not installed (`@objectstack/driver-turso`, `@objectstack/driver-mongodb`, `@objectstack/driver-sqlite-wasm`) is refused with that package's install alone, no longer followed by "Run `pnpm install && pnpm build`"
-
-Clause-②: no
-
-- **What was wrong.** A `turso`, `mongodb` or `sqlite-wasm` datasource whose driver package is not installed is refused at boot, and the refusal already says so and names the install (`npm install @objectstack/driver-turso`, with the `pnpm add` and `yarn add` spellings beside it). It then went on to end on the unbuilt-workspace remedy anyway: "… That is a build precondition … Run `pnpm install && pnpm build`, then start again." Those are two fixes that contradict each other, and the second does nothing for a project, which adds these optional packages itself. Measured on the published release with a declared `turso` datasource and no `@objectstack/driver-turso` installed.
-- **Now.** When the module that could not be resolved is one of those three packages, by its bare name (`Cannot find package '@objectstack/driver-turso'` under ESM, `Cannot find module '@objectstack/driver-turso'` under CJS), the refusal ends on one sentence that defers to the install named above it. It names no second command, no build step and no environment variable.
-- **Unchanged.** A driver package whose build output is missing (the error names a path into its `dist/`, which is what a monorepo checkout that has been installed but not built reports), or a required `@objectstack/…` package that is not installed, still ends on `pnpm install && pnpm build`. A missing `pg`, `mysql2` or `better-sqlite3` client still ends on the install of that client. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change, and the driver factory's own messages are unchanged: only the closing sentence of the refusal is different.
-
-What to do: install the driver package the refusal names next to the server that opens the datasource, then start again.
diff --git a/.changeset/24938-sys-user-security-tab-own-record.md b/.changeset/24938-sys-user-security-tab-own-record.md
deleted file mode 100644
index 3bc83d5f80c..00000000000
--- a/.changeset/24938-sys-user-security-tab-own-record.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/platform-objects': patch
----
-
-fix(platform-objects): the user page's Security tab shows only on the viewer's own record
-
-Clause-②: no (platform metadata only: the Security tab's self-service sections are shown on the viewer's own record only; no action, param, response, spec key or export moves)
-
-The `sys_user` record page's Security tab holds only self-service controls: change password and email, two-factor authentication, email verification and account deletion. Every one of those actions is already gated to the viewer's own record. On another user's record the actions were withheld, but the tab still showed its four "your account" headings and captions with nothing under them, addressed to an administrator about an account that is not theirs.
-
-The tab item now carries the own-record gate `has(record.id) && record.id == ctx.user.id` as an item-level `visibleWhen` in the CEL envelope (the same guard the page's unverified-email alert carries). On your own record the tab is unchanged. On anyone else's record the tab header and its panel are both omitted. The actions, their own gates and the other tabs are unchanged.
diff --git a/.changeset/24939-bu-parent-delete-restrict.md b/.changeset/24939-bu-parent-delete-restrict.md
deleted file mode 100644
index 0a40b5e0482..00000000000
--- a/.changeset/24939-bu-parent-delete-restrict.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@objectstack/platform-objects': minor
----
-
-fix(platform-objects)!: deleting a business unit that still has child units is refused, instead of silently turning each child into a root of the tenant
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (the delete of a business unit with children is refused), shipped as `minor` under the launch-window convention for accept-set narrowings.
-
-- **FROM:** `parent_business_unit_id` declared no `deleteBehavior`, so it took the lookup default `set_null`. Deleting a business unit that had child units answered `200`, and each child's `parent_business_unit_id` became `null`: a root of the tenant, without a word. A `unit_and_subordinates` sharing rule anchored above the deleted unit stopped reaching the former children.
-- **TO:** the same delete answers `409 DELETE_RESTRICTED`, naming the blocking object (`dependentObject: 'sys_business_unit'`) and the number of child units (`dependentCount`), with the sentence "This Business Unit is still referenced by N Business Unit record(s) through “Parent Business Unit”. Delete or reassign them first." The unit and its children are left exactly as they were. The field's help text now says a unit with child units cannot be deleted until they are re-parented or deleted.
-- **Unchanged:** a unit with no child units deletes as before. A bulk delete (`deleteMany`, `batch`) that lists the children before their parent (leaf to root) completes, atomic or not. Deleting an organization leaves its units and their tree in place, as before.
-- **Fix:** before deleting a business unit, re-parent its child units (edit each child's Parent Business Unit) or delete them first. A bulk delete of a whole subtree lists the units leaf to root; listed root first, the root is refused.
diff --git a/.changeset/24944-export-lookup-title-field.md b/.changeset/24944-export-lookup-title-field.md
deleted file mode 100644
index 7bf133a27ac..00000000000
--- a/.changeset/24944-export-lookup-title-field.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/rest': patch
----
-
-fix(rest): a lookup cell in the list export names the record by the target object's title field (#24944)
-
-Clause-②: no
-
-`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote a lookup, master-detail, user or tree cell from the lookup field's own `displayField`. Without one, it took the first key of a fixed list (`name`, `title`, `label`, `full_name`, …) found on the expanded record. It never read the target object's `nameField`. So a contact that declares `nameField: 'full_name'` and has no `name` column exported as its job `title` ("VP of Engineering" instead of "John Smith").
-
-- **Now.** The export reads each target object's title field once per export, by ADR-0079's `resolveDisplayField`: the `nameField`, then the deprecated `displayNameField` alias. The registry sets that pointer on every object it holds. The cell shows that field's value from the expanded record. This applies to csv, xlsx and json.
-- **Precedence.** The lookup field's own `displayField` still wins when it is set. The key list is used only when the target resolves no title field, or when its schema cannot be read.
-- **Field-level security.** When the caller cannot read the target's title field, the `$expand` read leaves it off the record. The cell then shows the record's id. Another column, such as the job title, no longer stands in for the name.
-- **Visible change.** A target with a literal `name` column and a different declared title now exports the declared title. For example, an account with `nameField: 'display_title'` exports its display title instead of `name`.
-- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes.
diff --git a/.changeset/24945-export-boolean-locale.md b/.changeset/24945-export-boolean-locale.md
deleted file mode 100644
index dd8e4ae3fda..00000000000
--- a/.changeset/24945-export-boolean-locale.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/rest': patch
----
-
-fix(rest): a boolean cell in the list export is written in the caller's language (#24945)
-
-Clause-②: no
-
-`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX / JSON) wrote every boolean or toggle cell as `是` or `否`, whatever the caller read. The header row of the same file follows the caller's locale, so an English caller got "Converted" and "Do Not Call" over Chinese cells.
-
-- **Now.** A boolean or toggle cell is written in the words the import template already uses for the caller's locale: `yes` / `no`, or `是` / `否` for a `zh*` locale. The locale is the one the header row is translated to: `Accept-Language`, then `?locale=`, then the deployment's default locale. The header and the cells always read one language. This applies to csv, xlsx and json.
-- **No locale at all.** When the request names no locale and the deployment declares no default, the cells read `yes` / `no`, the same words the import template writes then.
-- **Re-import.** A file exported in either language re-imports its boolean column: the importer reads `yes` / `no` and `是` / `否`.
-- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a boolean cell changes, and a Chinese caller's file is byte-for-byte what it was.
diff --git a/.changeset/24948-organization-delete-refuse-nonempty.md b/.changeset/24948-organization-delete-refuse-nonempty.md
deleted file mode 100644
index 9934930750e..00000000000
--- a/.changeset/24948-organization-delete-refuse-nonempty.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/plugin-auth': minor
----
-
-fix(objectql, plugin-auth)!: deleting an organization that still owns records is refused with 409 and a per-table count, before anything is deleted; an empty one is deleted whole, its teams and organization-admin grants with it; the engine never clears a tenant column
-
-**The 18.0.0 defect this fixes.** `POST /api/v1/auth/organization/delete` ran better-auth's delete, which removes the organization's members, then its invitations, then the organization row, as three separate writes. The engine's delete cascade then applied the tenant column's `set_null` default to every row the organization still owned. On a database whose tenant column is NOT NULL (one that took the v18 organization-ownership ceremony), the delete answered 500 on `sys_team.organization_id` after the members and invitations were already gone, so the organization survived with no owner and nobody could delete it. On a database 18.0.0 created, where the column is still nullable, the delete answered 200 and left the organization's teams and records with `organization_id = NULL`, owned by nobody and visible to nobody. Every organization created through the door owns a default team, so on a NOT NULL database not even an empty organization could be deleted. Deployments that run `single` are not reached the same way: their one organization is the Default Organization, which the last-administrator guard answers first, and they cannot create another.
-
-**What happens now.**
-
-- An organization that still owns records is refused with `409` and the standard `DELETE_RESTRICTED` code. The body names each table and its row count, `dependents: [{ object, count }]`, and never a row's values. The refusal runs before better-auth's handler, so nothing is written, the caller's active organization included. A caller the organization does not let delete it still gets better-auth's own `403`, with no count.
-- Append-only ledgers (objects declared `managedBy: 'append-only'`, such as `sys_activity`, `sys_audit_log` and `sys_email`) are history, not records. They never refuse the delete, and their rows are kept: every reference they hold to the organization (`organization_id` on `sys_activity` and `sys_email`, the attribution `tenant_id` on `sys_audit_log`) goes on naming the deleted organization, never NULL and never deleted. The delete writes activity rows of its own, so counting them would refuse every delete.
-- Engine-owned rows (objects declared `managedBy: 'engine-owned'`) are treated the same way. The platform writes them as a consequence of records or activity, and an administrator cannot delete them over the data API: approval requests, approvers and tokens; automation runs; notifications, their receipts and deliveries; outgoing HTTP deliveries; mailboxes and mirrored mail; record shares and share links; import jobs; audience-binding suggestions; and organization-level settings values and their audit. They never refuse the delete, and they are kept, still naming the deleted organization.
-- So engine-owned and append-only rows outlive the organization, and go on naming it. Under tenant scoping no organization reads them.
-- An attribution column `organization_id` on an object whose tenancy is disabled is kept the same way: not counted, not cleared. A pointer to the organization, such as a session's or an API key's `active_organization_id`, is cleared as before.
-- The Default Organization (slug `default`, or the deployment's only organization, never under an organization wall) is asked the last-administrator guard's question first, and answers `403 PERMISSION_DENIED` while deleting it would leave no platform administrator. Once the guard admits, it is counted like any other organization, before better-auth deletes anything, except that only its members and invitations are left out: its teams and grants are not removed, so they count, and the delete is refused `409 DELETE_RESTRICTED` while it holds them.
-- An organization that owns nothing but its own auth structure is deleted whole: its members and invitations (better-auth), and its teams, their members and the organization-admin grants its owners and admins hold (plugin-auth, from `beforeDeleteOrganization`). Any other grant scoped to the organization counts as a record.
-- The request runs as one engine transaction and rolls back on any error answer, so a delete either removes all of that or none of it. On a datasource whose driver has no transactions, the refusals are the guarantee, because all of them run before the first write: the record count above, and, for the Default Organization, the last-administrator guard, whose `403` is now asked before better-auth deletes any member (it used to arrive after the members and invitations were gone).
-- Under all of this, the engine itself refuses to delete a `sys_organization` row while any row still names it in its tenant column, whatever path reaches the delete: `409 DELETE_RESTRICTED` with the same `dependents` list, which better-auth's door now carries to the wire too. It counts each table rather than reading its rows. An organization is deleted by id only: a delete matching organizations by a filter (`multi: true`) is refused with `409 DELETE_RESTRICTED` naming the by-id route, because only the by-id delete runs this referential pass. A tenant column is never set to NULL.
-- `ObjectQL.organizationOwnedRowCounts(organizationId, { exclude })` answers, per object, how many rows name an organization in their tenant column. It is the question the refusal asks.
-
-Clause-②: yes (narrowing)
-
-
-
-**BREAKING** (the organization delete's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** deleting an organization that still owned records answered `500` after deleting its members and invitations (tenant column NOT NULL), or `200` with its teams and records orphaned to `organization_id = NULL` (tenant column nullable, an 18.0.0-born database). A direct engine delete of such a `sys_organization` row did the same.
-- **TO:** both answer `409 DELETE_RESTRICTED`, with `dependents: [{ object, count }]` naming every table that still holds rows. Nothing is deleted or changed.
-- **Fix:** delete the organization's records, or move them to another organization, then delete the organization. The refusal names the tables to look at. Business units, positions and sharing rules count as records. Activity, audit and mail-log rows do not, nor do engine-owned rows (approval state, runs, deliveries, shares, settings values).
-- **Seed datasets:** on a composition with the organizations runtime (`@objectstack/organizations`) and an app that ships seed datasets, a new organization receives that seed replay at creation, so it owns records from its first moment and its delete is refused until they are removed. The refusal lists them. Delete them (or move them) like any other records, then delete the organization. Measured on the showcase: a fresh organization owns 132 seeded rows in 19 tables.
-- **Already orphaned on 18.0.0?** Rows an earlier organization delete set to `organization_id = NULL` are not touched by this release. Organization-less rows are what the v18 organization-ownership ceremony (`os migrate organization-ownership`) attributes or reports.
diff --git a/.changeset/24949-migrate-apply-confirmation-exit.md b/.changeset/24949-migrate-apply-confirmation-exit.md
deleted file mode 100644
index 2847cdd5e31..00000000000
--- a/.changeset/24949-migrate-apply-confirmation-exit.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/cli': minor
----
-
-fix(cli)!: `os migrate apply` exits `1` when the confirmation it needs is not given, as every other `os migrate` subcommand with a `--yes` gate already does
-
-In a non-interactive shell (stdin is not a terminal) or under `--json`, `os migrate apply` without `--yes` applies nothing and prints `⚠ Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.` (under `--json`, its `confirmation_required` document). It exited `0` there, so a deploy script or an agent reading the exit status took a run that applied nothing for an applied one, and the next boot still reported the drift. Every other `os migrate` subcommand that writes behind `--yes` — `comment-reactions --apply`, `meta --stored --apply`, `recorded-by --apply`, `resume --run` and the rest — already exits `1` in that situation, as `organization-ownership --apply` does without `--backup-taken`. `apply` now answers the same way, in both faces. The warning and the `--json` document are unchanged.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (an exit status narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** `os migrate apply` (with or without `--allow-destructive`) on a database with changes to apply, without `--yes`, with stdin not a terminal or with `--json`: nothing applied, exit `0`.
-- **TO:** nothing applied, exit `1`. The printed warning and the `--json` document (`message: 'confirmation_required'`, `hint: 'pass --yes'`) are unchanged.
-- **Unchanged:** answering `n` at the `[y/N]` prompt in a terminal still prints `Aborted — no changes made.` and exits `0`; a run with `--yes` applies and exits `0`; a database with nothing to apply exits `0` without asking.
-- **The fix for a script:** a script that means to apply passes `--yes` (`os migrate apply --yes`, or `os migrate apply --allow-destructive --yes`); one that only wants to know what would change runs `os migrate plan`.
diff --git a/.changeset/24950-sqlite-open-failure-no-stepdown.md b/.changeset/24950-sqlite-open-failure-no-stepdown.md
deleted file mode 100644
index 990f4252d08..00000000000
--- a/.changeset/24950-sqlite-open-failure-no-stepdown.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/service-datasource': patch
----
-
-fix(service-datasource): in development, a SQLite datasource whose file cannot be opened or created fails its connect, as in production, instead of stepping down to an in-memory engine
-
-Clause-②: no
-
-`resolveSqliteDriver` probes a native `better-sqlite3` connection in development and steps down to wasm SQLite, then to an in-memory engine, when the probe fails. It used to step down on any probe failure. So a declared SQLite datasource whose file could not be opened read `connected` on a development boot (`NODE_ENV=development`, which the source-tree `bin/run-dev.js` sets). Examples: a path under a regular file, a parent directory the process cannot make, a permission refusal, a directory where the file should be. Every query on it then went to an empty in-memory store, so a maintenance command could report a store it never opened as examined and clean.
-
-- **Now:** a probe failure that is the database file failing to open or be created is re-thrown in development, as in production. The datasource's connect fails with the driver's `SqliteDatabaseFileError`, which names the file and the operating system's answer (`EEXIST`, `ENOTDIR`, `ENOENT`, `EACCES`, … or `SQLITE_CANTOPEN`). `listConnectionStates()` reports it as `failed`. This holds for every SQLite datasource, the primary included, so a development boot whose primary database file cannot be opened now stops and names the file.
-- **Unchanged:** a native addon that cannot load (an ABI mismatch, an addon that was never built, a missing `better-sqlite3`) still steps down to wasm SQLite, with the same warning. An absent file in a directory that exists, or that the driver can make, still opens natively and is created. Production behaviour is unchanged.
-
-What to do: if a development boot now refuses a SQLite datasource, fix its `filename`. The boot names the path it could not open.
diff --git a/.changeset/24951-duplicate-message-labels.md b/.changeset/24951-duplicate-message-labels.md
deleted file mode 100644
index e5e88935581..00000000000
--- a/.changeset/24951-duplicate-message-labels.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/objectql': patch
----
-
-fix(objectql): a unique refusal's user-facing sentence names the object and the field by label
-
-Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves)
-
-`DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`.
-
-- **The sentence now uses the declared `label`.** The engine looks up the object's and the field's labels in its registry on every write door that raises the refusal: insert (single and batch), and update by id and by predicate. Example: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` Where no label is declared, the sentence uses the API name, exactly as before. The opening words and `No record was written.` are unchanged.
-- **The API names stay available for diagnostics.** They are still on `object` and `field`, and `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. …`.
-- **Unchanged:** the code (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. `DuplicateRecordError`'s constructor takes an optional fourth argument with the labels. Callers that pass three arguments get the API-name sentence.
-- **For HTTP clients:** on the REST data routes the curated `error` text is unchanged. The wire `developerMessage` (which carries the engine's sentence) now names the labels, and the body's `object` and `field` keys still carry the API names.
diff --git a/.changeset/24953-page-predicate-bounds-warning.md b/.changeset/24953-page-predicate-bounds-warning.md
deleted file mode 100644
index 481f07aba77..00000000000
--- a/.changeset/24953-page-predicate-bounds-warning.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/lint': minor
----
-
-feat(lint): a CEL slot inside a page component's `properties` that overruns the platform's parse bounds is reported as a warning at `os validate`, `os build`, `os lint` and the page publish door
-
-Clause-②: no
-
-A source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. The component node's own `visibleWhen` was already refused for this (`visibility-predicate-over-budget`). The CEL slots declared inside the props bag were judged by no rule. These include a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. A page carrying such a slot saved clean through every door.
-
-The new rule `page-predicate-over-budget` (`validatePagePredicateBounds`) reads the slots off each component's `ComponentPropsMap` row: every position where the row's parse produces a `{ dialect: 'cel', source }` envelope. It judges each one with `validateExpression`'s bounds verdict and names the key and the bound. It does not echo the source. It is a **warning** in 18.x: the page still saves and builds, and the publish door carries the finding on its `advisories` channel. A future major release refuses it. A within-bounds source draws nothing.
-
-Fix: shrink the expression. Collapse an `||` chain into one `in [...]` test, or precompute the heavy part into a formula or roll-up field and test that field.
diff --git a/.changeset/24958-migrate-drop-gate-remedies.md b/.changeset/24958-migrate-drop-gate-remedies.md
deleted file mode 100644
index f577bea6777..00000000000
--- a/.changeset/24958-migrate-drop-gate-remedies.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/cli': patch
----
-
-fix(cli): `os migrate plan` and `os migrate apply` name the command each held drop waits for, never `--allow-destructive`, for every drop `apply` refuses (#24958)
-
-Clause-②: no
-
-`os migrate apply --allow-destructive` refuses the whole run, whatever flag is passed, while it would drop one of three columns before its precondition holds: `sys_account.issuer` while its retirement pre-flight finds `(provider_id, account_id)` keys held by more than one row (or cannot read `sys_account`), `sys_comment.reactions` while no verified run of `os migrate comment-reactions --apply` is recorded, and an `organization_id` column while the organization-ownership ceremony has not verified. Yet `os migrate plan` offered `--allow-destructive` for the first two, and `os migrate apply` without the flag listed all three with `"os migrate apply --allow-destructive" to drop it.` and closed with `N destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).` and `Skipped N destructive change(s) (re-run with --allow-destructive to include them).` That re-run was refused, exit 1.
-
-- **Now.** Each such drop is still listed and counted as destructive drift, in both commands. Its line names what it waits for: the ceremony, `os migrate comment-reactions`, or the retirement pre-flight `os migrate account-issuer`.
-- **The footer.** One line per gate that holds a drop names that gate's commands and says `--allow-destructive` is refused until they have run. `os migrate plan` offers `--allow-destructive` for nothing while a drop is held. `os migrate apply` prints `N destructive change(s) will be SKIPPED, and a re-run with --allow-destructive cannot include them yet:` before those lines, and `Skipped N destructive change(s); a re-run with --allow-destructive cannot include them yet (see above).` after the apply.
-- **Unchanged.** A database with no held drop prints what it printed before, byte for byte, in both commands. The `--json` documents, the exit codes and every refusal of `os migrate apply --allow-destructive` (its rule and its text) are unchanged.
-
-**What to do.** Nothing. Follow the footer: run the command each held drop names, then re-run `os migrate plan`.
diff --git a/.changeset/24960-sqlite-notadb-connect.md b/.changeset/24960-sqlite-notadb-connect.md
deleted file mode 100644
index ebee61af4ff..00000000000
--- a/.changeset/24960-sqlite-notadb-connect.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@objectstack/driver-sql': minor
-'@objectstack/service-datasource': minor
----
-
-fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)
-
-`SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `.corrupt-`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.
-
-`connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '': connect failed — `. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
-
-- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `.corrupt-` and an empty database was written in its place.
-- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
-- **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
diff --git a/.changeset/24961-readonly-probe-unopenable-path.md b/.changeset/24961-readonly-probe-unopenable-path.md
deleted file mode 100644
index 37b710bc3f5..00000000000
--- a/.changeset/24961-readonly-probe-unopenable-path.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/driver-sql': minor
----
-
-fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of opening an empty in-memory database over it and reporting it connected
-
-Clause-②: no (narrowing)
-
-
-
-`sqliteAbsentFile: 'empty-in-memory'` is the open mode a read-only boot uses (`os migrate plan`, and the dry runs that boot read-only): an absent SQLite file is opened as an empty in-memory database, so nothing is created on disk. Its judgement of "absent" was `existsSync(path) === false`, which is also the answer for a path that cannot exist: a path under a regular file (`ENOTDIR`), a directory the process may not search (`EACCES`). So `os migrate plan` printed `datasource '': connected` for a declared datasource on such a path and planned `create_table` into it, while the boot that writes refused the same path.
-
-**BREAKING** (the read-only boot's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** under `'empty-in-memory'`, a SQLite path that can be neither opened nor created read as absent. The driver opened `:memory:`, and the datasource's connect succeeded.
-- **TO:** `resolveSqliteAbsentFileTarget`, and so the `SqlDriver` constructor, throws `SqliteDatabaseFileError` naming the file and the operating system's answer: `ENOTDIR` (a path under a regular file), `EACCES` (a directory the process may not write or search), `EROFS` (a read-only mount), or `ENOENT` (a directory whose name a dangling symbolic link holds). The datasource's connect fails with it, as the boot that writes already failed, so `os migrate plan` reports the datasource failed instead of planning into it. The judgement still writes nothing: no directory and no file is created to find out.
-- **Unchanged:** an absent file in a directory that exists, or that could be made, still opens as an empty in-memory database and leaves the disk untouched. An existing file opens as before. The default mode (`'create'`) is untouched, and so is every server dialect: the option is now applied to SQLite clients only, as documented (a `pg` driver given it with a connection string used to have that string rewritten to `:memory:`).
-- **Fix:** if a read-only boot now refuses a SQLite datasource, correct its `filename`: the refusal names the path and the reason.
diff --git a/.changeset/24962-cbp-master-object-read.md b/.changeset/24962-cbp-master-object-read.md
deleted file mode 100644
index 477e887e711..00000000000
--- a/.changeset/24962-cbp-master-object-read.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): a `controlled_by_parent` detail's read scope requires object-level read on its master (ADR-0055 §3)
-
-Clause-②: no
-
-ADR-0055 §3 says reading a detail requires read access to its master, and §4 says a member who cannot read the master can neither read nor by-id-write a detail under it. The master-derived read scope of a `controlled_by_parent` object scoped the master's rows, but it did not ask whether the principal may read the master object at all. It now asks first, through the predicate `canReadObject` answers. A principal the master's own read door refuses at the object gate gets an empty master set, so it reads no detail under that master.
-
-- **Every door that composes the scope.** The engine read door (`find`, `findOne`, `count`), the delegator half of an on-behalf-of read (ADR-0090 D10), `getReadFilter`, and every hop of a chain all answer this way.
-- **The write doors follow.** A by-id update or delete of such a detail answers the read door's not-found (`RECORD_NOT_FOUND`, 404), as it does for any row the principal cannot read. A predicate update or delete matches no such detail.
-- **The engine already gave this answer.** Its ADR-0139 `ReadableIds` leaf asks `canReadObject` before it scopes a parent's rows. The id-list derivation and the leaf now agree.
-- **Unchanged.** A principal that may read the master object keeps its previous answer, whether or not its master rows are scoped. So do a system context, a super-user grant, and an on-behalf-of read whose delegator may read the master.
diff --git a/.changeset/24964-orphan-detail-operator-command.md b/.changeset/24964-orphan-detail-operator-command.md
deleted file mode 100644
index 1c68fa241cc..00000000000
--- a/.changeset/24964-orphan-detail-operator-command.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-'@objectstack/cli': minor
-'@objectstack/objectql': minor
----
-
-feat(cli, objectql): `os db orphans` — report the detail rows whose required master-detail master does not exist, and delete them only behind `--delete`, after a pre-delete export
-
-Clause-②: yes (widening)
-
-**What was wrong.** A system-context write is exempt from the write-time reference check, so an elevated writer could store a master-detail id that names no master. Such a row's access derives from that master, so every user door answered 404 for it, the administrator's `GET` and `DELETE` included, and a list query left it out. The integrity audit reported the row and, by design, never rewrites it. No door existed to remove it, so it stayed until someone edited the database by hand.
-
-**`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it.
-
-- **The default run is a report** that writes no row or table and creates no file. It boots read-only, as `os migrate plan` does. It lists each detail row whose required `master_detail` master does not exist, with its object, record id, field and missing master id. It also lists the deeper detail rows that the delete's cascade removes with it. `--json` prints one document, `{ mode: 'report', plan }`.
-- **What is never deleted, and is said.** A reference whose target object could not be checked (not registered, no driver, or a failed read) is counted as `undetermined`. Unknown is not missing. A dangling reference on the same objects through a field that is not a required master-detail is listed in `otherDangling`. An orphan whose cascade could not be read is listed as `withheld`, with the reason. A scan bounded by `--max-rows` (default 100000) names every object it could not read in full.
-- **`--delete` requires `--export `.** The export holds every record the run deletes, as the driver stores it. It is written owner-only, is created rather than overwritten, and is read back before the first delete. If it cannot be written or does not hold every record, the run refuses with nothing deleted. Just before deleting, the run reads the report again and skips an orphan whose master now exists, or whose cascade now reaches a record the export does not hold. Each delete is the engine's own system-context delete, so the master-detail's cascade removes the deeper details. A `restrict` dependent refuses the delete, and a cascade on one datasource is one unit of work, so nothing of that orphan is removed. A `set_null` reference to a deleted row is cleared. A roll-up on the missing master cannot be recomputed, and the run notes this rather than failing. Each delete is judged by reading its records back.
-- **Refusals**, each exit 1 with nothing deleted, and the code under `--json` as `error`: `export_required`, `export_exists`, `export_failed`, `export_incomplete`, `confirmation_required` (`--json` without `--yes`), `boot_failed`, `no_engine` and `scan_failed`.
-
-**`@objectstack/objectql`: `ObjectQL.inspectCascadeDelete(object, id)` (new method).** A read-only method that returns every record a by-id `delete()` of that record removes, root first. It is the cascade set the delete collects for itself, read through the same walk. It writes nothing, runs no hook, and files no reference-check elevation record, because no delete happens. It reads as the system with no organization. `restrict` and `set_null` relations are not listed, because neither removes a record. A dependents probe that fails propagates.
-
-Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged.
diff --git a/.changeset/24965-script-template-tombstone.md b/.changeset/24965-script-template-tombstone.md
deleted file mode 100644
index eb3dad6dc16..00000000000
--- a/.changeset/24965-script-template-tombstone.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-fix(spec): the `script.config.template` tombstone points at the `notify` node's own `template` key
-
-Clause-②: no (prose only: no shape, key, default or accepted value moves)
-
-The rejection an author sees for the retired `script.config.template` key said that stored templates live in the messaging service (`sys_notification_template`). That text predates the `notify` node's own `template` key. It now prescribes that key: name an email template (`email_template`) in a `notify` node's `template`, with `templateData` filling its placeholders, and the email and in-app inbox channels render it in each recipient's locale; otherwise the node carries inline `title`/`message`. It mentions `sys_notification_template` only as the rows the `email` and `sms` channels read, `email` only when the node names no template. The key stays rejected exactly as before, and the generated reference page carries the same text.
diff --git a/.changeset/24968-page-tabs-item-label-key.md b/.changeset/24968-page-tabs-item-label-key.md
deleted file mode 100644
index 70c68ecf921..00000000000
--- a/.changeset/24968-page-tabs-item-label-key.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/lint': minor
-'@objectstack/cli': minor
----
-
-feat(spec): a `page:tabs` item label gains a translation key, `pages..components..items..label`
-
-Clause-②: yes (widening)
-
-- **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
-- **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
-- **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
-- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
-- **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
-- **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
diff --git a/.changeset/24969-form-view-title-translation-key.md b/.changeset/24969-form-view-title-translation-key.md
deleted file mode 100644
index 96b4a7eeb69..00000000000
--- a/.changeset/24969-form-view-title-translation-key.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/rest': minor
-'@objectstack/lint': minor
-'@objectstack/cli': minor
----
-
-feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language
-
-Clause-②: yes (widening)
-
-- **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
-- **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
-- **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
-- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
-- **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
-- **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
diff --git a/.changeset/24974-skills-install-line.md b/.changeset/24974-skills-install-line.md
deleted file mode 100644
index ad18da907ee..00000000000
--- a/.changeset/24974-skills-install-line.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/skills': patch
----
-
-docs(skills): the npm page and the catalog's front page give the install line for the project's own package manager
-
-Clause-②: no
-
-`README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) printed `npm install --save-dev "@objectstack/skills@$(node -p …)"` as the only install line. In a pnpm-installed project that line exits `1` with `npm error Cannot read properties of null (reading 'edgesOut')` and writes nothing, and `create-objectstack` installs with pnpm when it is available, so a scaffolded project is commonly a pnpm project.
-
-- **Both pages** now print `pnpm add -D "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"`, with the npm and yarn spellings on a comment line beside it, and the rule: add the package with the manager that installed the project. The `objectstack-upgrade` skill's re-install step, which an agent follows for this exact move, prints the same line.
-- **The npm page** names pnpm's `ERR_PNPM_NO_MATCHING_VERSION` beside npm's `ETARGET` for a project on `@objectstack/spec` 17.x, and runs `npx skills list` before `npx skills remove --all`, which removes every installed skill without asking.
diff --git a/.changeset/24978-approval-notifications-recipient-language.md b/.changeset/24978-approval-notifications-recipient-language.md
deleted file mode 100644
index b86f966f8ee..00000000000
--- a/.changeset/24978-approval-notifications-recipient-language.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/plugin-approvals': patch
----
-
-fix(plugin-approvals): approval notifications reach each recipient in their own language
-
-Every approval notification now hands the messaging service its sentence as catalog keys beside the English literal (`EmitInput.keyedText`). The messaging service renders it at delivery in each recipient's `sys_user.locale`, else the deployment default. This plugin's translation bundle gains the sentences in en, zh-CN, ja-JP and es-ES, in its `messages` face (`messages.approvalRequestedTitle` and the rest).
-
-- **Which notifications.** `approval.requested`, `approval.reminder`, `approval.sla_breached` (to the approvers and to the submitter), `approval.approved` and `approval.rejected`, the two out-of-office notices (`approval.ooo_substituted`, `approval.ooo_skipped`), `approval.escalated`, `approval.reassigned` and `approval.returned` (the send-back and the auto-rejection). For `approval.comment`, `approval.request_info` and a send-back that carries the approver's note, only the headline is keyed. The body is that person's own words, delivered as written.
-- **The record is named as before:** the object's label and the record id (`Deal D_123`), never a title. The label is rendered from the app's `objects..label` when its language pack holds one, else the schema label.
-- **The English is unchanged.** The payload's `title` and `body` are the `en` catalog entries, byte for byte the sentences each notification carried before. A messaging service that does not render keyed text delivers them as it always did.
-- **Unchanged.** Who is notified, each notification's topic, payload fields, deep link, dedup key and organization, and every channel's delivery behaviour apart from the text.
diff --git a/.changeset/24978-keyed-notification-text.md b/.changeset/24978-keyed-notification-text.md
deleted file mode 100644
index a1bbda075d3..00000000000
--- a/.changeset/24978-keyed-notification-text.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@objectstack/service-messaging': minor
----
-
-feat(service-messaging): a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language
-
-Clause-②: yes (widening): `EmitInput` gains an optional `keyedText` member, and the package's `.` entry gains `NotificationTextRenderer`, `assertNotificationTextKeys` and their types. An emit without `keyedText` is accepted and delivered exactly as before.
-
-- **What was wrong.** `emit()` took the text a recipient reads only as `payload.title` and `payload.body`, literals the producer builds before any recipient exists. So every recipient of a notification read the producer's language. The approvals plugin's notifications reached zh-CN, ja-JP and es-ES approvers in English.
-- **The new member.** `EmitInput.keyedText` is `{ titleKey?, bodyKey?, params? }`. `titleKey` and `bodyKey` are `II18nService` keys made of a face and an id, such as `messages.approvalRequestedTitle`. A producer contributes their entries through its own translation bundle (`i18n.loadTranslations`, ADR-0029 D8). `params` fills the entries' `{{name}}` holes. A param is a string, or `{ key, fallback }`: the recipient-language text of `key` (such as `objects..label`), else `fallback`.
-- **Where it renders.** At delivery, per recipient, before the channel is called: on the inline fan-out, on each row the dispatcher sends, and on each row of a digest group. The language is the recipient's own `sys_user.locale`, else the deployment default (the recipient-locale chain, `resolveRecipientLocale`), negotiated against the locales the `i18n` service holds. Every channel, a third party's included, receives the rendered `title` and `body` it already reads.
-- **The fallback.** The literal `payload.title` and `payload.body` stay what a recipient reads whenever a key cannot be rendered: no `i18n` service, no renderer composed, or a key with no entry in the recipient's locale or in the catalog's declared fallback locale. A member the producer left unkeyed is also delivered as the literal. A key with no entry is said once per key and locale at `warn`.
-- **Refused at emit.** A malformed `keyedText` throws a `TypeError` before anything is written: an undeclared member, a key without a face, a param name no `{{hole}}` can spell, or a keyed param without its `fallback`.
-- **The delivery snapshot reserves `keyedText`,** the way it already reserves `title`, `body`, `severity`, `actionUrl` and `actorId`. The snapshot stores `EmitInput.keyedText` under that name only when the emit keyed its text. A producer's own payload member of that name (a flow `notify` node's free-form payload, say) is dropped from the snapshot and is never rendered. No producer in this repository writes one.
-- **Cost.** One `sys_user` read per keyed delivery (one per digest group). A delivery without keys, or a deployment with no `i18n` service, makes no read.
-- **Unchanged.** Every emit that passes no `keyedText` (and whose payload has no member of that name): the event row, the snapshot, and what the inbox, email and SMS channels deliver. The notify `template` path, `sys_notification_template` rendering (which now falls back to the rendered text), recipients, preferences and channel availability are also unchanged.
-- **Composing it.** `MessagingServicePlugin` wires one `NotificationTextRenderer` into the service and the dispatcher. A hand-built `MessagingService` passes `textRenderer` in its context and a `NotificationDispatcher` passes it in its options. Without one, keyed text is delivered as its literal, and the service says so once.
diff --git a/.changeset/24979-approvals-label-live.md b/.changeset/24979-approvals-label-live.md
deleted file mode 100644
index ce2c487ba8e..00000000000
--- a/.changeset/24979-approvals-label-live.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-docs(spec): `ApprovalRequestRow`'s display labels are documented as the reader's-locale label
-
-Clause-②: no (ledger and docblock text only: no shape, key, default, accepted value or export moves)
-
-The `ApprovalRequestRow` docblocks for `process_label`, `step_label`, `current_step_label` and `object_label` named the authored or schema label. Since plugin-approvals serves those members in the reader's locale, they now say so: the served value is the translation picked from `flows..label`, `flows..approvals..label` or `objects..label` in the read's `ExecutionContext.locale`, falling back to the stored snapshot (or the object's schema label). The stored `sys_approval_request` columns are unchanged. The `flows.approvals` translation liveness rows read `live`. No type, key or export changes.
diff --git a/.changeset/24981-import-lookup-stored-title.md b/.changeset/24981-import-lookup-stored-title.md
deleted file mode 100644
index 992d2b4f5dc..00000000000
--- a/.changeset/24981-import-lookup-stored-title.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@objectstack/core': patch
----
-
-fix(core): the import resolves a lookup cell by the target object's stored title field
-
-Clause-②: no
-
-- **Before.** A lookup cell was matched against the target by its id, then by the lookup's own `displayField`, then by a fixed list of keys (`name`, `title`, `label`, `full_name`, `email`, `username`). The target object's declared title field was never asked. So a cell that named the record by its title, such as `Wren` for a target whose title is `nickname`, answered `reference_not_found`, although the list export writes lookup cells by that same title.
-- **Now.** After the id and the lookup's `displayField`, the import asks the target's title field, resolved by ADR-0079's one rule (`resolveDisplayField`: `nameField`, then the deprecated `displayNameField` alias, then the derived title). It is read from the same object declaration the import already reads to check the target's exposure, so no extra metadata read is made. Two records that share the title answer `reference_ambiguous`, like the import's other ambiguous matches. The cell is never linked to the first hit.
-- **A formula title is not matched.** A `formula` field has no stored column, and the engine refuses a filter over it. The import no longer sends that query, for the title or for any other candidate the target declares as a formula, so a cell against a formula title still answers `reference_not_found`. How a formula title should be matched is still open.
-- **Unchanged.** The fixed fallback keys and their order. The exposure check still runs first: a target whose declared exposure does not serve `list` answers `reference_not_found` for every cell and is never read.
diff --git a/.changeset/24983-template-channel-refusal.md b/.changeset/24983-template-channel-refusal.md
deleted file mode 100644
index e2ff1c13ff5..00000000000
--- a/.changeset/24983-template-channel-refusal.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/service-messaging': minor
----
-
-fix(service-messaging)!: `sys_notification_template` refuses a write that leaves a row naming a channel other than `email` or `sms`, because no channel ever sends such a row
-
-A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row for `inbox`, `push` or any other value was stored and then rendered nowhere; the `channel_renders_template` rule only warned about it. That rule now refuses. It is an invariant judged over the stored row overlaid with the write, so it answers rows already stored with another channel as well as new ones: such a row is refused on any edit that leaves its channel out of the set, until an edit moves it to `email` or `sms`, and it is still read, listed and deleted. No stored row is rewritten or deleted by this change.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (a platform object's write accept set narrows, stored rows included), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row naming a channel other than `email` or `sms` was accepted and stored, with at most a `channel_renders_template` advisory in the write answer's `warnings`. A row already stored with such a channel took every edit.
-- **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding on `channel` (`code: 'rule_violation'`); an import commit fails that row with `field: 'channel'` and `code: 'rule_violation'`; a bulk update fails that row with `code: 'VALIDATION_FAILED'` and `httpStatus: 400`, and a predicate update over a set holding such a row writes no row. Every refusal carries the prescription: set the channel to `email` or `sms`, or delete the template; in-app content is an email template (`email_template`) named in a notify step's `template`.
-- **Unchanged:** `email` and `sms` rows and every write to them; reading, listing and deleting any row; how every channel renders, so a row for another channel is still sent by none; and the `channel` column and its width.
-- **The fix for an operator:** a row for another channel was never sent. Delete it, or set its channel to `email` or `sms` if its text belongs there. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it.
diff --git a/.changeset/24986-cbp-master-edit-object-question.md b/.changeset/24986-cbp-master-edit-object-question.md
deleted file mode 100644
index 06da031be41..00000000000
--- a/.changeset/24986-cbp-master-edit-object-question.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): a `controlled_by_parent` detail write asks its master the object-level question the master's own write door asks (ADR-0055 §3)
-
-Clause-②: no
-
-ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master. The master-edit gate's object-level leg asked only for an object-level `update` grant on the master. It now asks what the master's own write door asks of an `update` on the master, with that door's inputs: the master's security posture must resolve, the capabilities the master requires for `update` (ADR-0066 D3) must be held, and the grant is asked under the master's posture (ADR-0066 D2). A principal the master's own write door refuses at its object gate can no longer write a detail under that master.
-
-- **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg.
-- **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. A principal that also cannot read the master, on an on-behalf-of write too, now gets this `PERMISSION_DENIED` on insert where it got the reference check's `VALIDATION_FAILED`. An insert under a master object that does not resolve answers `PERMISSION_DENIED` where it answered `OBJECT_NOT_FOUND` (404).
-- **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before.
diff --git a/.changeset/24991-nested-props-non-record.md b/.changeset/24991-nested-props-non-record.md
deleted file mode 100644
index e896556342a..00000000000
--- a/.changeset/24991-nested-props-non-record.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-"@objectstack/lint": minor
----
-
-fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component whose `properties` is present but not an object, nested components included (#24991)
-
-Clause-②: no (narrowing: `os lint --strict` refuses a nested page component whose `properties` is `null` or an array)
-
-A page component's `properties` is a record on every node. `PageComponentSchema` refuses anything else, but that parse only reaches the top-level components (`regions[].components[]` and `slots.*`). A nested component, in a container's `children`, a tab or accordion panel's `items[].children` or a card's `footer`, sits in an untyped slot that no parse judges. The component-props rule (`validateComponentProps`) then skipped a nested node whose bag was `null`, an array or a scalar. So a nested `record:related_list` with `properties: null`, or a nested `object-metric` with `properties: []`, passed `os lint --strict`, `os validate` and `tsc`, and the page published a component that had lost every prop it was authored with. An absent bag was already judged as `{}`; this was the present-but-not-an-object residual of the same skip.
-
-The rule now refuses such a bag on every walked node, whatever its `type`, with one `component-props-invalid` error located at the node's `properties` path (for example `pages[0].regions[0].components[0].properties.children[0].properties`). The message names what was received (`null`, an array, a string). No per-prop verdicts are added beside it.
-
-**BREAKING — what moves for consumers.**
-
-- `os build`, `os validate` and `os lint` fail on a page whose nested component carries `properties: null`, `properties: []` or a scalar `properties`. Before, they passed.
-- A page write in publish mode that carries such a component answered 200. It now answers `422 INVALID_METADATA` with a `component-props-invalid` issue at that path, because the page save door runs this same rule.
-
-**Remedy.** Write the bag as an object of the props the component reads (`properties: { … }`), or omit the key when the component needs none. An omitted bag is judged as `{}`, so a component with required props still has to name them.
-
-**Unchanged.**
-
-- A top-level component's non-object bag is refused by the page parse, as before, and that parse runs before any authoring rule at both doors, so no second finding appears beside it.
-- An absent bag keeps its earlier verdict: it is judged as `{}`.
-- A valid bag is judged exactly as before. An unregistered component `type` with an object bag is still skipped.
-- The finding takes the rule's existing id and severity (`component-props-invalid`, `error`). No public export or signature moves.
-- Stored pages are not migrated and are not refused on read. A stored page with such a component keeps loading until it is next saved, and that save is judged.
-- Measured before crossing: no page this repository ships authors a non-object `properties` bag.
-
-
diff --git a/.changeset/24994-webhook-credential-marker.md b/.changeset/24994-webhook-credential-marker.md
deleted file mode 100644
index f4485026446..00000000000
--- a/.changeset/24994-webhook-credential-marker.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-spec!: `WebhookSchema` declares `secret` (a credential value) and `headers` (a credential map, as a whole) with `.meta({ credential: 'value' | 'map' })`, and `getMetadataTypeRedactor(type)` gains a third resolution — after explicit registrations and built-ins — a redactor derived from the credential markers on the type's registered schema. Every meta read door now withholds a webhook's `secret` and `headers`, with or without `@objectstack/plugin-webhooks` mounted (ruling B+ on #24966).
-
-**BREAKING:** the `/meta` read doors no longer serve a webhook's `secret` or `headers`.
-
-Clause-②: no (narrowing) — the `/meta` read doors return less for `webhook`: a stored `secret` or non-empty `headers` map is dropped from the served item and named in `redactedKeys`; the cleared forms (an empty string, an empty map) are served as written. A save that omits the two fields keeps the stored values (the metadata protocol's carry-forward); to remove a credential, save its cleared form. The dispatcher reads the declaration itself, so delivery signing is unchanged.
-
-A metadata type that stores a credential in a declared field marks it on its schema instead of registering a redactor; a spec ratchet reds on a credential-named field that carries no marker.
-
-
diff --git a/.changeset/24996-flow-failed-inner-refusal.md b/.changeset/24996-flow-failed-inner-refusal.md
deleted file mode 100644
index cb0bc700fcc..00000000000
--- a/.changeset/24996-flow-failed-inner-refusal.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-feat(spec): a failed flow run can carry the refusal its write node met — `AutomationResult.userMessage` / `AutomationResult.innerRefusal`, and one `FlowFailureDetailsSchema` for every door's `400 FLOW_FAILED` details
-
-Clause-②: yes (widening)
-
-The maintainer's 「同意」 on ruling B′ of #24914 (decision batch #379 item 5): the inner refusal of a refused flow write node reaches the client structurally, the way a record save's refusal does, and its code takes the wire spelling. This release is the contract half. The engine and the four doors start emitting the new members when the services and runtime half (#24914) lands; until then nothing on the wire changes.
-
-- **`AutomationResult` (`@objectstack/spec/contracts`) gains two optional members.** `userMessage` has `ApiErrorSchema.userMessage`'s semantics: it is producer-marked end-user text, present only when the inner refusal's producer opted in at throw time, and it never replaces `error`. `innerRefusal` is `{ code, message }`. Its `code` is the wire spelling a record save of the same write answers in `error.code` (`UNIQUE_VIOLATION` for a unique refusal), parsed against the same closed `ErrorCode` set `ApiErrorSchema.code` uses. `TriggerFlowResponseSchema.data` mirrors both, so the `TriggerFlowResponse['data']` = `AutomationResult` parity pin holds.
-- **New `FlowInnerRefusalSchema` and `FlowFailureDetailsSchema` (`@objectstack/spec/api`).** The second one is the `error.details` of the `400 FLOW_FAILED` arm of the trigger, resume, `/actions` and endpoint doors, declared once: `errorMessage?`, `summary?` and `innerRefusal?`. On the resume door, `ResumeFailureDetailsSchema`'s `runId` / `status` / `repairable` sit beside them in the same object, and each schema parses its own members.
-- **The #9585 `/actions` ceiling, relaxed by the ruling.** That ceiling was exactly two keys in `error.details` (`errorMessage`, `summary`) and no new schema key. Ruling B′ adds one key and names it, `innerRefusal`, so the ceiling is now the three keys `FlowFailureDetailsSchema` declares, at every door.
-- **Unchanged.** `error.message` keeps each door's wording byte for byte, `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union the #9378 ruling settled. The inner code is nested in `innerRefusal`, so no new ledger code is added. Every new member is optional: an envelope without them parses exactly as before.
-
-**For consumers.** A flow runner can show a failed run's refusal the way a record form shows a save's refusal. Read the envelope's top-level `userMessage` when it is present. Otherwise read `details.innerRefusal` (`code` and `message`), and fall back to `message` last. A client running behind the server ignores the new members and keeps its current behaviour.
diff --git a/.changeset/24997-webhook-capability-required.md b/.changeset/24997-webhook-capability-required.md
deleted file mode 100644
index 9358df691cb..00000000000
--- a/.changeset/24997-webhook-capability-required.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/spec': minor
----
-
-feat(spec)!: `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with `STACK_WEBHOOK_CAPABILITY_REQUIRED`
-
-A declared webhook is dispatched by two capabilities together: `'webhooks'` mounts `@objectstack/plugin-webhooks`, whose auto-enqueuer turns a record event into a delivery, and `'realtime'` mounts `@objectstack/service-realtime`, that auto-enqueuer's only source of record events. Neither token implies the other, and a scaffolded app's `requires` is `['automation', 'triggers']`, which carries neither. So a stack that declared a webhook without both validated, built and booted; the activation door answered `enabled: true`; every record write answered `200`; and no delivery was ever enqueued. The only trace was one boot `error`, after deploy. `defineStack` now refuses such a stack at load, the third declared-capability refusal beside `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and `STACK_TRIGGER_CAPABILITY_REQUIRED`, so every door that loads it (`os validate`, `os build`, `os serve` / `os dev`, an app's own `bootStack` tests) stops with the same sentence.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (the `defineStack` accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** a stack with a non-empty top-level `webhooks:` collection and a `requires` missing `'webhooks'`, `'realtime'` or both (an absent `requires` included) was accepted, and its webhooks never delivered.
-- **TO:** `defineStack` throws `STACK_WEBHOOK_CAPABILITY_REQUIRED` (`status: 422`), header `defineStack webhook capability validation failed (N issues):`, with one `issues` entry per declared webhook naming the webhook and the missing token or tokens. One code for every arm: which token is missing is in the finding, not in the code.
-- **Unchanged:** a stack with no declared webhook owes neither token; `defineStack(config, { strict: false })` skips the judgement by contract; the activation door answers as before; and the boot `error` that `@objectstack/plugin-webhooks` logs for a host that wires a stack by hand, past `defineStack`, stays. Every declared webhook counts, `isActive: false` included, because the activation door can switch it on.
-- **The fix for an author:** add both tokens to the stack's `requires`, beside whatever it already declares, for example `requires: ['automation', 'triggers', 'webhooks', 'realtime']`. The refusal's own line names exactly the tokens to add.
diff --git a/.changeset/25000-strict-stored-expression-refusal.md b/.changeset/25000-strict-stored-expression-refusal.md
deleted file mode 100644
index 6c25f379ea6..00000000000
--- a/.changeset/25000-strict-stored-expression-refusal.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/spec': patch
----
-
-fix(objectql)!: under `strictReadonlyWrites`, a write whose value a stored field's `expression` replaces (`stored_expression`) is refused, and nothing is written
-
-Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`)
-
-
-
-**BREAKING** (the strict mode's refusal set grows by one reason), shipped as `minor` under the amended ruling 「甲」: a narrowing that no conversion carries ships now, in 18.x. It applies only to a caller that opts into the in-process `strictReadonlyWrites` option. No REST or wire caller can set that option, and every write without it behaves exactly as before.
-
-`strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed, one reported drop was left out: a caller's value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict, such a write completed and was only reported. It is now refused like every other strip's drop.
-
-- **FROM:** a write with `strictReadonlyWrites: true` that sent a value for a stored expression field, different from the value the engine computes, completed. The row held the computed value, and `onFieldsDropped` received a `stored_expression` event.
-- **TO:** the same write throws `ReadonlyFieldRejectedError` (`code: 'ERR_READONLY_FIELD_REJECTED'`), whose `drops` carries the `stored_expression` event. Nothing is written, and `onFieldsDropped` does not fire. This holds on insert (a single row, a batch, and `insertMany`, which refuses the whole call as it does for the other strict drops), on update by id, and on a predicate update.
-- **One refusal names everything.** When the same payload also carries another strip's drop (`readonly`, `readonly_when`, `primary_key`, `computed`), the one refusal names all of them together, `stored_expression` last. Before, such a write was refused for the other drops alone. The message's per-reason form gains a `stored_expression` remedy, spoken only when that reason is present, so every other refusal's message is unchanged.
-- **Ordering is kept.** The strips' refusal still comes first: if the stored-expression pass itself throws, for example a predicate update whose patch computes different values on different matched rows (`VALIDATION_FAILED`), a strict write that also carries a strip's drop is still refused with `ERR_READONLY_FIELD_REJECTED` naming those drops, as before.
-- **`@objectstack/spec` (patch):** the TSDoc on `WriteObservabilityOptions.strictReadonlyWrites` and `DroppedFieldsEvent.reason`, which ships in the published declarations, now says the `stored_expression` arm is refused under strict. No schema, `.describe()` text or export changes.
-- **Unchanged:** a strict write that sends the computed value, or no value for the field, is not refused, because nothing it sent was replaced. A stored expression field also declared `readonly` is still refused once, as `readonly`. Without `strictReadonlyWrites`, the write completes with the computed value and the replacement is reported, as before. The error code is unchanged.
-- **The fix for a strict caller:** leave the stored expression field out of the payload, or send the value its expression computes. To let the replacement happen and merely observe it, drop `strictReadonlyWrites` and pass `onFieldsDropped`.
-
-This supersedes the `strictReadonlyWrites` bullet of the `stored_expression` reason's entry, which deferred this refusal to a later major.
-
-Measured before the change: no non-test caller in this repository passes `strictReadonlyWrites` (the examples, dogfood suites and runtime doors pass none), so no in-repo producer moves.
diff --git a/.changeset/25002-page-predicate-over-budget-error.md b/.changeset/25002-page-predicate-over-budget-error.md
deleted file mode 100644
index d7f4dd64430..00000000000
--- a/.changeset/25002-page-predicate-over-budget-error.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@objectstack/lint': minor
----
-
-fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component props CEL slot over the platform's parse bounds (#25002)
-
-Clause-②: no (narrowing: a page component props CEL slot over the platform parse bounds is refused at `os build` and the page door; it ships now under the amended 「甲」, #6015 `6303865025`)
-
-A CEL source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. `page-predicate-over-budget` (`validatePagePredicateBounds`) finds such a source in the CEL slots declared inside a page component's `properties` bag: a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. The finding was a warning. It is now an error, and its authoring-rules entry moves from the advisory tier to the gating tier. This supersedes the warning-only behaviour described for the same rule earlier in this release line; its message no longer says "A future major release refuses it."
-
-**BREAKING — what moves for consumers.**
-
-- FROM: a warning at `os validate`, `os build` and `os lint`, and an entry on the page save door's `advisories` channel while the page saved. TO: an error. The three commands fail, and an active page write answers `422 INVALID_METADATA` with a `page-predicate-over-budget` issue located at the slot and naming the bound it overruns. The page is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
-- The page door answers `INVALID_METADATA`, its existing author-time refusal envelope, not `VALIDATION_ERROR`.
-
-**Fix (one line).** Shrink the predicate (collapse an `||` chain into one `in ['a', 'b', …]` test), or precompute the heavy part into a formula or roll-up field on the object and test that one field.
-
-**Unchanged.**
-
-- A within-bounds source draws nothing at any door.
-- The component node's own `visibleWhen` keeps its existing verdict (`visibility-predicate-over-budget`), and this rule does not report it twice.
-- Stored pages are not migrated and are not refused on read. A stored page with such a slot keeps loading and serving until it is next saved, and that save is judged. Saving a page as a draft is still allowed; publishing the draft is judged.
-- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
-- No public export or signature moves; `PagePredicateBoundsFinding.severity` is now `'error'`.
-- Measured before crossing, through the built `@objectstack/lint`: 0 in-repo producers carry such a slot. Every page module under `examples/` and `packages/` (30 files, 65 page values) draws 0 findings, and so do the `app-crm`, `app-multi-package` and `app-todo` composed stacks. A literal scan of 9,919 tracked source files (13,840 string literals of 120+ characters) finds 0 sources over the bounds, and HotCRM at `c8a7a70` (611 files) finds 0. A planted 78-term control is found by both instruments.
-
-
diff --git a/.changeset/25009-readable-ids-delegated-compose.md b/.changeset/25009-readable-ids-delegated-compose.md
deleted file mode 100644
index e2af54e0452..00000000000
--- a/.changeset/25009-readable-ids-delegated-compose.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/objectql': minor
-'@objectstack/plugin-security': minor
----
-
-feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered)
-
-Clause-②: no
-
-**Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe.
-
-**Now.**
-
-- `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope.
-- `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`.
-
-**What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright.
-
-**Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection.
diff --git a/.changeset/25010-readable-ids-nested-compose.md b/.changeset/25010-readable-ids-nested-compose.md
deleted file mode 100644
index 264bb167fed..00000000000
--- a/.changeset/25010-readable-ids-nested-compose.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/objectql': minor
----
-
-feat(objectql): the engine composes a `ReadableIds` leaf that sits inside a composed read scope — the nested master chain (ADR-0139 D6 as amended)
-
-Clause-②: no
-
-**Before.** The executor boundary composed every `ReadableIds` leaf in a read's own `where` with the parent's read scope, and handed the driver that scope as answered. A scope that itself carried a leaf — a parent whose read scope is derived from ITS master, `{ master_fk: { $in: ReadableIds.of(G) } }`, the shape a two-level `controlled_by_parent` chain takes once its scope is spelled as a leaf — reached the driver uncomposed inside the parent's subquery, and the driver refused it, `INTERNAL_ERROR` / 500 (measured on driver-sql).
-
-**Now.** Every leaf a composed scope carries is composed the same way, before the leaf that encloses it — the same five steps, the same caller context at every level (a delegated context included), recursively. The driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested; a two-level chain serves exactly the rows today's id-list pre-scan serves, for a direct and for a delegated caller (measured on the real stack: `find` and `count` agree, one statement per read). Each parent is asked once per read, at whichever level it first appears. The closed doors hold at every level: `canReadObject` false, the deny sentinel, a throw on either half, or a parent the registry does not hold answer `$in: []` inside the composed scope. Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: D5's two classes at a nested level (a middleware-gated or federated master), a nested leaf outside a `$in` comparand or a composed leaf the engine did not compose inside a scope, a chain that returns to a parent already on its branch, and a chain deeper than 8 scopes (the same cut as the `controlled_by_parent` derivation's).
-
-**What a reader sees.** No shipped security service answers a read scope carrying a leaf yet — `controlled_by_parent`'s scope is still the pre-resolved id list (ADR-0055 §2 mechanism (b)) until the plugin-security switch lands on its own card — so every shipped read is served as before. The six consumers' leaves compose exactly as they did: a scope with no leaf is composed by reference, one level asked.
-
-**Unchanged.** `controlled_by_parent`'s own read scope and the id-list pre-scan, ADR-0055, every consumer's probe routing, and the drivers' compilation of the composed leaf (driver-sql and driver-memory already compile a scope that holds a composed leaf, through their own filter path).
diff --git a/.changeset/25012-cbp-write-shape-master-edit.md b/.changeset/25012-cbp-write-shape-master-edit.md
deleted file mode 100644
index 04181a401ac..00000000000
--- a/.changeset/25012-cbp-write-shape-master-edit.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@objectstack/plugin-security': minor
----
-
-fix(plugin-security)!: an update or delete of a `controlled_by_parent` detail asks for edit access to its master on every write shape (ADR-0055 §3)
-
-Clause-②: no (narrowing)
-
-
-
-ADR-0055 §3 says editing or deleting a detail requires edit access to its master, and it names no write shape as exempt. The master-detail write gate decided one shape of write to a `controlled_by_parent` detail without asking that question. It now asks it: the gate collects the masters of the detail rows the write would reach, as the caller's own read returns them, and runs on each master the check a by-id write runs. One master the caller may not edit refuses the whole write, and nothing is written. On a write made on a user's behalf, both principals are asked, as on a by-id write.
-
-**BREAKING** for a caller that relied on that shape to write details under a master it may not edit. Shipped as `minor` under the launch-window convention for accept-set narrowings.
-
-- **Before.** That shape of write changed or removed details under a master the caller may not edit, bounded only by what the caller could read and, for an organization member, by the platform ownership floor.
-- **Now.** It answers the refusal a by-id write of the same detail answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. A write whose rows all sit under masters the caller may edit lands as before and writes the same rows. A row the caller cannot read is not judged and is not written.
-- **Also refused.** A caller whose read of the detail is itself refused gets that read's refusal for this shape of write, because the masters of rows it cannot read cannot be established. A write reaching more than 10,000 readable rows answers `INVALID_FILTER` (400), the refusal the same count already answers.
-- **Unchanged.** Inserts and by-id updates and deletes; system contexts; the detail's read scope; the platform ownership floor; and every object that does not declare `controlled_by_parent`.
-
-**What to do.** Grant the caller edit access on the master, or narrow the write to details under masters the caller may edit.
diff --git a/.changeset/25013-select-shorthand-keeps-written-value.md b/.changeset/25013-select-shorthand-keeps-written-value.md
deleted file mode 100644
index 6a43256ad6a..00000000000
--- a/.changeset/25013-select-shorthand-keeps-written-value.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/metadata-core': minor
-'@objectstack/platform-objects': patch
----
-
-fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value
-
-Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead)
-
-Metadata-protocol: narrowed
-
-
-
-**BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes.
-
-`Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`.
-
-- **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads.
-- **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier).
-- **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before.
-- **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`.
-
-`@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite.
-
-`@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated.
diff --git a/.changeset/25019-sqlite-file-failure-family.md b/.changeset/25019-sqlite-file-failure-family.md
deleted file mode 100644
index a940ce43f6f..00000000000
--- a/.changeset/25019-sqlite-file-failure-family.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@objectstack/driver-sql': minor
-'@objectstack/service-datasource': minor
----
-
-fix(driver-sql): a SQLite database whose header SQLite cannot read fails `connect()` at once, naming the file, and a dangling symbolic link as the database's directory is refused on the boot that writes, as the read-only boot already refused it (#25019)
-
-Three more SQLite files a declared datasource cannot use, each now classified by the file's own driver at connect:
-
-- **A database whose header or schema SQLite cannot read** (a file truncated inside its 100-byte header, or short of the pages its schema names). The connection's first statement, `PRAGMA auto_vacuum`, answers `SQLITE_CORRUPT`; `SqlDriver.connect()` logged it as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and resolved over a file no statement could read. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) then stepped down to wasm SQLite, whose open renamed the file to `.corrupt-` and wrote an empty database in its place, under a warning that blamed the native addon. `connect()` now refuses it with a `SqliteDatabaseFileError` (`code: 'SQLITE_CORRUPT'`, `filename`, a message naming the file) in every mode, before anything is written, and `resolveSqliteDriver` re-throws it instead of stepping down. A database damaged deeper inside still connects: its first statement answers, and the damage surfaces on the read that reaches it, as before.
-- **A dangling symbolic link as the database's directory, on the boot that writes.** `connect()` took `mkdir`'s `EEXIST` on the link for a concurrent creator and resolved; the first statement then failed with a code-less "directory does not exist", and a development boot stepped down to wasm SQLite under the same false native-addon warning. `connect()` now refuses it with `SqliteDatabaseFileError` (`code: 'ENOENT'`), naming the file and the link, which is the error the read-only boot (`sqliteAbsentFile: 'empty-in-memory'`) gives for the same path.
-- **A dangling symbolic link as the database file itself, on the read-only boot.** When the directory its chain of links ends in does not exist, the read-only boot read the file absent and reported the datasource connected, while the boot that writes refuses the same path with `SQLITE_CANTOPEN`. The read-only boot now refuses it too (`code: 'ENOENT'`), naming the link's target. When that directory exists, both boots behave as before: the read-only boot opens an empty in-memory database, and the boot that writes creates the file through the link.
-
-A real database file, an absent file in a directory that exists or can be made, and a native addon that cannot load over a real database keep today's behaviour.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (`SqlDriver.connect()`'s accept set and the read-only boot's narrow): a connect that resolved over a SQLite database whose header SQLite cannot read, or over a path whose directory is a dangling symbolic link, now rejects; a read-only boot that read a dangling file link absent although its target's directory does not exist now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
-
-- **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot a damaged file was renamed to `.corrupt-` and replaced with an empty database, and a dangling-link directory stepped down to wasm SQLite under a warning that blamed the native addon. The read-only boot reported a dangling file link connected.
-- **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_CORRUPT'` for the damaged file and `code: 'ENOENT'` for the dangling-link directory, naming the file (and the link); the read-only boot rejects the dangling file link with `code: 'ENOENT'`, naming its target. The file is untouched.
-- **Fix:** restore the database from a backup, or move the damaged file aside yourself (an absent file is created empty on the next connect); point the dangling link at a directory that exists, or point the datasource at a real path.
diff --git a/.changeset/25020-turso-encryption-key-local.md b/.changeset/25020-turso-encryption-key-local.md
deleted file mode 100644
index fcfebd168fd..00000000000
--- a/.changeset/25020-turso-encryption-key-local.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-'@objectstack/spec': minor
-'@objectstack/driver-turso': minor
-'@objectstack/service-datasource': patch
----
-
-fix(spec,driver-turso)!: a turso datasource that declares `encryptionKey` on a local file or an embedded replica is refused where it is written and when the driver is built, instead of being accepted and written in plaintext
-
-Clause-②: no (narrowing) — the accept set of the `turso` `datasource.config` contract, of the published `TursoConfigSchema` mirror and of `new TursoDriver()` narrows by one key beside a `file:` url or `:memory:`, in every mode. No key is added, removed or renamed, and no exported symbol moves. `@objectstack/service-datasource` is graded `patch`: the TSDoc of its exported `TursoDriverConfigInput.encryptionKey` no longer promises an encrypted file, and nothing else in it changes.
-
-`encryptionKey` was declared as an "AES-256 encryption key for the local database file", and nothing applied it. The turso driver reads and writes a local or replica file through better-sqlite3, which has no encryption codec (`PRAGMA key` is a silent no-op there). Measured on the driver source with a test key: a local datasource with `encryptionKey` constructed, connected and wrote its rows, and plain better-sqlite3, opening the file read-only with no key, read them back. An embedded replica fared no better: only its sync client was handed the key, after the local engine had already written the file in plaintext, and a file that client encrypted is one the local engine cannot open. An operator who declared the key got an unencrypted file and no signal.
-
-**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). A non-empty `encryptionKey` on a local datasource (a `file:` url or `:memory:`) or an embedded replica (a `file:` url beside `syncUrl`), detected or forced with `mode`, is refused now, at both doors together, with one message per mode that names the mode and what does encrypt. The key is judged by where the database lives, not by the mode: the same `file:` url or `:memory:` under a forced `mode: 'remote'` is refused too, in its own words, because the driver hands that url to the libSQL client it builds, which is handed no key, so the file was written in plaintext all the same:
-
-- **at authoring**, as one `custom` issue on `encryptionKey` (`config.encryptionKey` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
-- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
-
-For a local or replica datasource the message is the same text at all three places; for a `file:` url or `:memory:` under a forced `mode: 'remote'`, the schema and the constructor give the forced-remote text, while the driver mirror, which declares no `mode` and strips it, refuses the same config in the local text. A test holds the constructor's copy equal to the schema's issue byte for byte in every mode, and the mirror's verdict and words equal to the schema's wherever no `mode` is forced. It comes last among the local-engine refusals: a url the engine cannot open, a lone `sync` and a forced `mode` the driver would ignore each keep their own message. An empty `encryptionKey` is unset and is still accepted. A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file.
-
-### Migration: FROM → TO
-
-| You wrote | Write instead |
-| --- | --- |
-| `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
-| `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way |
-| `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way |
-| either, where the data must be encrypted at rest and no encrypted volume is available | a remote Turso database (`url: 'libsql://my-db.turso.io'`), whose storage encryption is the server's to configure, not this key's |
-
-The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
-
-A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext.
-
-Blast radius, measured on this tree: no example, template, CLI scaffold or published skill authors the key, and no host default or environment variable sets it (a turso `encryptionKey` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.
-
-
diff --git a/.changeset/25030-preflight-issuer-physical-read.md b/.changeset/25030-preflight-issuer-physical-read.md
deleted file mode 100644
index 9c3e34cb2b9..00000000000
--- a/.changeset/25030-preflight-issuer-physical-read.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@objectstack/plugin-auth': minor
-'@objectstack/cli': patch
----
-
-fix(plugin-auth): `os migrate apply`'s `sys_account.issuer` pre-flight report names each colliding row's stored issuer instead of `(none)`
-
-Clause-②: yes (widening)
-
-`probeAccountIdentityCollisions` read `sys_account` through the engine's `find`. The engine answers declared columns only, and the shipped `sys_account` no longer declares `issuer`, so the report `os migrate apply --allow-destructive` prints when it refuses the `sys_account.issuer` drop labelled every colliding row's issuer `(none)`, in the human report and in the `--json` document's `preflight.collisions[].issuers` alike. `os migrate account-issuer` read the same database through the driver and printed the stored values. The issuers are what an operator needs to decide which colliding row survives.
-
-- **`@objectstack/plugin-auth`.** When its source offers the storage door (`getDriverForObject`, as the `objectql` engine both CLI doors pass does), the probe reads `sys_account` through the driver the engine routes that name to, and through nothing else: a missing driver is refused, never answered from the engine's `find`. A source that offers `find` alone is still read as handed. A new option, `onTableAbsent`, reads a missing `sys_account` table as no rows and reports that it did. Without it, a missing table is refused like any other failed read.
-- **Widened public surface (`@objectstack/plugin-auth`).** The package entry re-exports the pre-flight module, so it now publishes two new types, `AccountTableStorage` (`getDriverForObject`) and `AccountTableDriver`, and a new option, `AccountIdentityPreflightOptions.onTableAbsent`. `probeAccountIdentityCollisions` now accepts `AccountTableStorage | AccountIdentityReadEngine` as its source. Every earlier caller still type-checks, and nothing narrows.
-- **`@objectstack/cli`.** `os migrate account-issuer` reads through the probe's reader instead of its own, so it and `os migrate apply` read one table one way. Its output is unchanged.
-
-**Unchanged.** The collision verdict (`ok`, the colliding keys, row ids, users, counts), the `apply` refusal and its exit code. A database whose `sys_account` has no `issuer` column reports each row as `(none)`, as before.
diff --git a/.changeset/25044-cbp-every-master-edit.md b/.changeset/25044-cbp-every-master-edit.md
deleted file mode 100644
index b7b7be8f6a1..00000000000
--- a/.changeset/25044-cbp-every-master-edit.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@objectstack/plugin-security': minor
----
-
-fix(plugin-security)!: an update of a `controlled_by_parent` detail also asks for edit access on the master its body names (ADR-0055 §3)
-
-ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master, and it reads a create's master from the master id in the incoming body. A write to a `controlled_by_parent` detail can involve more than one master, and for one shape of write the master-detail write gate asked the master-edit question of one of them only. It now asks it of every master the write involves, with the same check and in the same order as before for the one it already asked. One master the caller may not edit refuses the whole write, and nothing is written. On a write made on a user's behalf, both principals are asked, as on every other detail write.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** for a caller that relied on that shape of write landing while one of the masters it involves is a master the caller may not edit. Shipped as `minor` under the launch-window convention for accept-set narrowings.
-
-- **Before.** That shape of write was decided on one of the masters it involves, and landed when the caller could edit that one, whatever the other.
-- **Now.** It answers the refusal a write under the other master answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. A write whose masters the caller may all edit lands as before and writes the same rows. A write that reaches no row involves no master and is not refused.
-- **Unchanged.** Inserts and deletes; every write that involves only the master its rows already sit under; system contexts; the detail's read scope; the platform ownership floor; and every object that does not declare `controlled_by_parent`.
-
-**What to do.** Grant the caller edit access on every master the write involves, or keep the write within masters the caller may edit.
diff --git a/.changeset/25049-cbp-every-row-master.md b/.changeset/25049-cbp-every-row-master.md
deleted file mode 100644
index 75633584d31..00000000000
--- a/.changeset/25049-cbp-every-row-master.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-'@objectstack/plugin-security': minor
----
-
-fix(plugin-security)!: a batch create of a `controlled_by_parent` detail asks for edit access on the master of every row it names, and an update no longer empties a detail's master reference (ADR-0055 §1, §3)
-
-ADR-0055 §3 says creating, editing or deleting a detail requires edit access to its master, and it reads a create's master from the master id in the incoming body. A batch create carries one body per row. The master-detail write gate read a create's master only from a body that is a single record, so a batch create asked about no master at all. That covers the engine's array insert and `insertMany`, and the bulk-create and import doors that call them. The gate now asks about the master of every row the batch names, with the same check a single create runs, once per master and in row order. One master the caller may not edit refuses the whole batch, and nothing is written. On a write made on a user's behalf, both principals are asked, as on every other detail write.
-
-ADR-0055 §1 derives a detail's access from exactly one required master. For a master reference that required-field validation does not judge, an update whose body set the reference to null landed. It stored a detail that no principal's read can match. The gate now refuses it as it already refuses a create that names no master.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** for a caller that relied on either write landing. Shipped as `minor` under the launch-window convention for accept-set narrowings.
-
-- **Before.**
- - A batch create of a detail landed whatever masters its rows named, including masters the caller may not edit.
- - An update that set a master reference, of a kind required-field validation does not judge, to null landed and stored the null.
-- **Now.**
- - That batch create answers the refusal a single create of the offending row answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. The partial-success `insertMany` refuses the whole call rather than reporting that row.
- - A batch whose rows' masters the caller may all edit lands as before and writes the same rows.
- - That update answers `MISSING_REQUIRED_FIELD` (422) and writes nothing. Where required-field validation does judge the reference, it still answers the update, as before.
-- **Unchanged.**
- - Single creates, and every update and delete that does not empty the master reference.
- - A row naming no master, which the same rules as before answer.
- - System contexts, the detail's read scope and the platform ownership floor.
- - Every object that does not declare `controlled_by_parent`.
- - The import door degrades a refused batch to one create per row. There, the rows under masters the caller may edit still land, and the other row is refused on its own.
-
-**What to do.** Grant the caller edit access on every master a batch names, or keep each batch within masters the caller may edit. To move a detail to another master, write that master's id, never null.
diff --git a/.changeset/25053-oauth-application-type-options.md b/.changeset/25053-oauth-application-type-options.md
deleted file mode 100644
index 3b1b6a9c247..00000000000
--- a/.changeset/25053-oauth-application-type-options.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/platform-objects': minor
----
-
-fix(platform-objects)!: `sys_oauth_application.type` declares only `web` and `native`, the two application types its writer produces
-
-Clause-②: no (narrowing) — the stored column's declared options, which `GET /api/v1/meta/object/sys_oauth_application` serves and the record validator judges a write against, lose `useragentbased` and `public`, which no writer produces.
-
-
-
-**BREAKING** (a platform object's declared option set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-The column's only writer is the OAuth provider (`@better-auth/oauth-provider`), whose `application_type` plugin-auth maps onto `type`. The provider writes `web` or `native` on every creation and update path, or no type for a client-metadata-document client that names none. The generic data API cannot write the table. The column declared four options, and the string shorthand that declared them strips hyphens, so it served `web`, `native`, `useragentbased` and `public`. No writer produces the last two, and the `user-agent-based` an earlier provider line stored was never one of them.
-
-- **FROM:** the `type` field of `sys_oauth_application` declared and served the options `web`, `native`, `useragentbased` and `public`. A write that sent `useragentbased` or `public` was accepted and stored.
-- **TO:** it declares and serves `web` and `native`. A write that sends any other value, including `useragentbased` and `public`, is refused with `VALIDATION_FAILED` (`400` at an HTTP boundary) and an `invalid_option` finding on `type`. No door sends one: the provider sends only `web`, `native` or no type, and the generic data API answers `405` for every write to this table.
-- **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. The register-form entry in this release refers to this entry for the stored column's options.
-- **The fix:** nothing to change for a client the provider registered. A row an earlier provider line stored as `user-agent-based` keeps that value. To edit it through the provider's update path, set `application_type` (`web` or `native`) in the same update.
diff --git a/.changeset/25058-trustproxy-three-consumers.md b/.changeset/25058-trustproxy-three-consumers.md
deleted file mode 100644
index 1e7c2890ccd..00000000000
--- a/.changeset/25058-trustproxy-three-consumers.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-docs(spec): `server.trustProxy`'s description names all three consumers of the client-address rule
-
-Clause-②: no (prose only: no shape, key, default or accepted value moves)
-
-The `.describe()` on `server.trustProxy` said the client-address rule had two consumers: the inbound rate limiter and the authentication plugin. There is a third. A declared endpoint (`apis:`) whose own `rateLimit` is enabled is metered by the endpoint policy chain, which keys anonymous callers by the same rule under the same `trustProxy`, whether or not `server.security.rateLimit.enabled` is set. The description and the TSDoc now name it. No key, type, default or accepted value changes.
diff --git a/.changeset/25061-notification-template-html-escape.md b/.changeset/25061-notification-template-html-escape.md
deleted file mode 100644
index c771718922d..00000000000
--- a/.changeset/25061-notification-template-html-escape.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/service-messaging': minor
----
-
-fix(service-messaging)!: an html or mjml `sys_notification_template` body escapes every hole's value for markup
-
-The email channel renders a notification template row whose `format` is `html` or `mjml` into the mail's html part. Its `{{ payload.x }}` holes placed each value into that markup as stored, so a text field carrying markup characters — a person's display name, a record title, a comment — was read as markup by the recipient's mail client. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike, and the template's own markup renders as written. Stored text is never stripped or refused: a display name may contain any character, and the mail client shows it as typed.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (what an html or mjml notification template renders for a markup-bearing value narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** in an html or mjml `sys_notification_template` body, a `{{ payload.x }}` hole rendered its value verbatim, so a value carrying markup became markup in the sent html part.
-- **TO:** the same hole renders the value HTML-escaped: an ampersand, an angle bracket or a quote arrives as the matching HTML entity, which a mail client displays as the original character.
-- **Unchanged:** the subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. A value without markup characters renders byte-identically. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged, and no stored row is rewritten.
-- **What to do:** a template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.
diff --git a/.changeset/25063-delegated-organization-membership.md b/.changeset/25063-delegated-organization-membership.md
deleted file mode 100644
index 8439378b35a..00000000000
--- a/.changeset/25063-delegated-organization-membership.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/plugin-security': minor
----
-
-fix(plugin-security)!: a delegated operation runs only in an organization the delegator is a member of, on a walled posture
-
-ADR-0090 D10 bounds an on-behalf-of operation by the delegator's scope. The operation runs in the live principal's active organization, and the delegator context is resolved in that same organization, on the stated assumption that the agent and its delegator are in the same organization. Nothing checked that assumption. The delegated-principal prologue (`resolveOperationPrincipals`) now checks it with `vetOrganizationClaim`, the one rule for "a membership backs this organization": under `isolated` or `group`, when the on-behalf-of context carries an active organization the delegator is not a member of, every operation is refused before any gate runs.
-
-Clause-②: no (narrowing) — on a walled posture, an operation of an on-behalf-of context whose active organization the delegator's membership does not back is refused `403 PERMISSION_DENIED`, where it used to run.
-
-
-
-**BREAKING** (the security middleware's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **FROM:** under `isolated` or `group`, an operation (read, insert, update or delete) of an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of ran, answered by the gates the middleware composed for the two principals.
-- **TO:** the same operation is refused `403` with `code: 'PERMISSION_DENIED'` before any gate runs. The message names the organization and the delegator.
-- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization (it keeps the ADR-0123 D2 answers), a context without `onBehalfOf`, a dangling delegator (still refused as before), and system contexts. The `/mcp` OAuth door, the one producer of an on-behalf-of context in the platform, vets the request's organization against the same membership, so the contexts it builds are never refused by this rule.
-- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand (a plugin, a service, an agent runtime) must set its `tenantId` to an organization the delegating user is a member of, or leave it unset.
diff --git a/.changeset/25065-email-service-is-configured.md b/.changeset/25065-email-service-is-configured.md
deleted file mode 100644
index e263769a4cd..00000000000
--- a/.changeset/25065-email-service-is-configured.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/spec': patch
-'@objectstack/plugin-email': patch
----
-
-feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered
-
-Clause-②: yes (widening: an optional member on `IEmailService`)
-Metadata-protocol: none
-
-- **The contract.** `IEmailService` (`@objectstack/spec/contracts`) gains one optional member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. `true` means the active transport hands mail to a real delivery provider. `false` means no mail leaves the process: sends still resolve and still write their audit row. A `log` transport is never delivering, in any environment. The answer is read at call time and follows a transport swapped in at runtime.
-- **Absent means unknown.** The member is optional, so every existing implementation stays valid. A consumer must never read an absent member as delivering: a flow whose success depends on a mail reaching someone treats absent the same as `false` and keeps its explicit refusal, so a service that is merely wired is never mistaken for one that delivers.
-- **The implementation.** `@objectstack/plugin-email`'s `EmailService.isConfigured()` answers `false` while its transport is the `LogTransport` (the default when no mail transport is configured) and `true` for any other transport. It follows the transport the plugin rebuilds from the `mail` settings, so saving an SMTP or API-provider configuration turns it `true` without a restart.
-- **Unchanged.** Sending, templates, persistence and the boot warning for the `log` transport behave as before. No consumer reads the member yet.
diff --git a/.changeset/25077-rest-docs-viewer-attr-escape.md b/.changeset/25077-rest-docs-viewer-attr-escape.md
deleted file mode 100644
index f048f0606fe..00000000000
--- a/.changeset/25077-rest-docs-viewer-attr-escape.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@objectstack/rest': patch
----
-
-fix(rest): the API docs viewer escapes the request-derived openapi URL for the attribute it lands in
-
-The `GET {base}/docs` viewer page builds the sibling openapi URL from the request and places it into a `data-url="…"` attribute of a script element. It interpolated that value into the attribute with no escaping, so a character the attribute context interprets reached the served markup as itself rather than as the matching HTML entity. The value is now escaped for its attribute context (ruling 3C on #25040: every outlet that renders a value into HTML escapes it by the context it lands in): the five characters a quoted attribute or element text interprets become their entities, and a browser decodes each back to the original character for the page script. A URL carrying none of those characters is served byte-identically.
-
-Clause-②: no
diff --git a/.changeset/25079-federated-report-policy-denied.md b/.changeset/25079-federated-report-policy-denied.md
deleted file mode 100644
index 1b910bdc781..00000000000
--- a/.changeset/25079-federated-report-policy-denied.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/objectql': patch
----
-
-fix(objectql): a federated object whose datasource the connect policy denied is reported at info, not in the ERROR that claims its reads hit the wrong table
-
-Clause-②: no (log text and level only: no response, status, outcome, boot verdict or export moves)
-Metadata-protocol: none (a boot log line's level and text; nothing a metadata author writes changes)
-
-- **What it was.** At `kernel:ready`, `ObjectQLPlugin` re-binds every federated (`external`) object to its remote table and reports, at ERROR, each one it could not bind. The ERROR says the object stays served while every read against it "resolves to a table named after the OBJECT … no such table, or the wrong table". It said that about an object whose datasource the host's connect policy had denied on purpose. `os migrate apply` on the stock showcase, whose managed-only policy denies every `schemaMode: 'external'` datasource, printed it after a successful apply.
-- **What changed.** When the datasource layer records the datasource as refused by policy (availability `blocked`), its unbound objects are reported in one `info` line instead. The line names the objects and their datasource, and says every read against them is refused with `DatasourceUnavailableError` until the policy allows that datasource. That is what the engine already does for such a read. The same line appears on every composition, serving or not, because it follows the recorded verdict alone.
-- **Unchanged.** The ERROR, word for word, for every other object it covered: a datasource that was never declared, a connect that FAILED (availability `failed`), a driver without external-object registration, and a registration that threw. On a boot with both, the info line covers the denied objects and the ERROR covers the rest, and the ERROR's counts exclude the denied ones. No binding, connect policy or read result changes.
-- **What to do.** Nothing. A log filter or agent that keyed on the ERROR for a policy-denied datasource now finds the info line, which names the same objects and datasource.
diff --git a/.changeset/25084-sqlite-refused-connect-release.md b/.changeset/25084-sqlite-refused-connect-release.md
deleted file mode 100644
index 059e3795534..00000000000
--- a/.changeset/25084-sqlite-refused-connect-release.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@objectstack/driver-sql': patch
----
-
-fix(driver-sql): a SQLite `connect()` that refuses its file closes the connection it opened before it rejects
-
-Clause-②: no (resource release only: no response, status, error, outcome or export moves)
-Metadata-protocol: none
-
-- **What it was.** `SqlDriver.connect()` refuses a declared SQLite file its first statement cannot use (`SqliteDatabaseFileError`: `SQLITE_CANTOPEN`, `SQLITE_NOTADB`, `SQLITE_CORRUPT`). The file was opened to give that answer, and the pool kept the better-sqlite3 connection after the rejection. For a damaged file whose header says WAL, SQLite kept its `-shm` and an empty `-wal` beside the file until something called `disconnect()`. A datasource left unconnected on the degraded path is never disconnected, so it kept them for the life of the process.
-- **What changed.** When a SQLite `connect()` rejects, it destroys its pool and puts a fresh, empty one in its place, then rethrows the same error: same class, code, message and `cause`. Nothing is left open, and the `-shm` / `-wal` siblings are gone when the rejection arrives. A later `connect()` on the same instance opens the file again: it gives the same refusal while the file is bad, and connects once the file is repaired. A release that itself fails is logged at `warn`, and the original refusal is still the error thrown.
-- **Unchanged.** Which files are refused, the codes and the messages. Server dialects (`pg`, `mysql`) are untouched: their `connect()` rejects only when no connection could be made, so there is nothing to release. `SqliteWasmDriver` and the local mode of `TursoDriver` inherit the release through `super.connect()`.
-- **What to do.** Nothing.
diff --git a/.changeset/25096-better-auth-1-7-12.md b/.changeset/25096-better-auth-1-7-12.md
deleted file mode 100644
index 2e716be2a18..00000000000
--- a/.changeset/25096-better-auth-1-7-12.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-'@objectstack/plugin-auth': patch
-'@objectstack/spec': patch
-'@objectstack/cli': patch
-'@objectstack/service-datasource': patch
----
-
-fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
-
-`@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
-
-Clause-②: no (narrowing)
-Metadata-protocol: none
-
-
-
-**BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
-
-- **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
- **TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
-- **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
- **TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
-- **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
- **TO:** it answers `400` with `error: 'invalid_grant'`.
-- **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
- **TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
-- **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
- **TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
-- **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
- **TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
-- **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
- **TO:** the same `401` also clears the stale session cookies.
-- **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
-- **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
-- **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
-- **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
-- **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".
diff --git a/.changeset/25097-cloud-connection-platform-managed.md b/.changeset/25097-cloud-connection-platform-managed.md
deleted file mode 100644
index c4608342d9f..00000000000
--- a/.changeset/25097-cloud-connection-platform-managed.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@objectstack/cloud-connection': minor
----
-
-fix(cloud-connection)!: a runtime holding the environment service key is platform-managed, so it registers no Cloud Connection Setup page and refuses to disconnect; everywhere else the Cloud Connection entry moves to Setup's Integrations group
-
-A runtime whose control-plane credential is the environment service key (`controlPlaneApiKey`, or `OS_CLOUD_API_KEY`, which the cloud sets on the environments it hosts) is connected by the platform, not by anyone on the runtime. It still registered the Cloud Connection page in Setup's Apps group, which read "Connected" and offered Disconnect. Disconnecting there cleared the runtime's stored device credential while the service key stayed in use, so the page went on reading "Connected", and the control plane was asked to revoke a connection the platform had made.
-
-Such a runtime now registers neither the page nor its navigation entry, and says so once at boot. A request to disconnect it is refused with `409` and `RESOURCE_CONFLICT`, after the sign-in and Setup Access checks and before anything is revoked or cleared. The message says the platform manages the connection.
-
-A self-hosted or local runtime, connected through the device-code flow, keeps the page and its disconnect unchanged. Its Cloud Connection entry moves from the Apps group to the Integrations group, after Webhooks, Datasources and Connect an Agent. That group requires `manage_platform_settings`, so a user who holds Setup Access without it, such as an organization administrator, no longer sees the entry. The connect and disconnect requests still take Setup Access alone.
-
-Clause-②: no (narrowing)
-
-
-
-**BREAKING** (on a runtime holding the environment service key, a disconnect request it carried out is now refused, and the Cloud Connection page and its navigation entry are no longer served), shipped as `minor` under the launch-window convention for breaking changes.
-
-- **Unchanged:** a runtime without the service key, apart from the entry's group. The connection status answer is unchanged on every runtime.
-- **Nothing to rewrite:** a cloud-hosted environment's connection is the platform's to manage. A self-hosted server leaves `OS_CLOUD_API_KEY` unset, as the deployment guide says; one that sets it is platform-managed by this rule.
diff --git a/.changeset/console-cf153eb2e535.md b/.changeset/console-cf153eb2e535.md
deleted file mode 100644
index e7a1ab2a80d..00000000000
--- a/.changeset/console-cf153eb2e535.md
+++ /dev/null
@@ -1,81 +0,0 @@
----
-"@objectstack/console": minor
----
-
-Console (objectui) refreshed to `cf153eb2e535`. Frontend changes in this range:
-
-Derived from the changesets objectui declared over the range — 65 releasing of 66 changesets added across 69 non-merge commits; omitted: 1 release-nothing changeset, 3 commits carrying no changeset (they ship no package code).
-
-- **minor** — `NavigationItemSchema` now refuses a navigation entry whose `visible` reads `data` but which declares no `dataSource`, as `@objectstack/spec`'s `NavigationItemSchema` already does… (objectui `8556295f0`)
-- **patch** — The permission-set editor no longer keeps the clone notice once the copy is held or saved (objectui#13272). (objectui `cf153eb2e`)
-- **patch** — Setup › Packaged automation: the clone result now offers the next steps (objectui#13270). (objectui `5235d6d57`)
-- **patch** — The README's "Setup half of an item" now says what objectstack's shipped organization administrator set does on the assignment objects: it creates and removes rows on `sys_user_po… (objectui `b001d211d`)
-- **patch** — The package edit dialog (Studio / Setup) now edits a package's `dependencies`. The row is no longer locked once the package exists, and saving sends it to `PATCH /api/v1/packages/… (objectui `36b70c055`)
-- **patch** — A row action's `defaultFromRow` param backed by a relation is seeded with the related record's id again, not the whole record (objectui#13271). (objectui `8e649da7d`)
-- **patch** — A `kind:'react'` page keeps its own state when its host re-renders (objectui#13268). The page was recompiled whenever it received a new schema object, and the console hands it a f… (objectui `8cd4db497`)
-- **patch** — A record drawer stays open on its first open (objectui#13238). On the first open in a page session, the drawer's body is drawn for the first time once the record is read, and its… (objectui `a29f61576`)
-- **patch** — Settings pages refuse to save while a visible required setting is empty (objectui#13239). (objectui `1ca759a16`)
-- **patch** — The command palette, the inbox and the AI entry ask before they drop a page's unsaved edits (objectui#13253). (objectui `c8833c5b8`)
-- **patch** — A category x axis rotates its labels, and cuts them at 12 characters, only when the measured band cannot hold them (objectui#13241). A full-width bar chart now draws a name such a… (objectui `9a4ddb2a9`)
-- **patch** — The language menu asks before a switch drops a page's unsaved edits (objectui#13254). The console shell remounts every page on a language change, so switching the language on a pa… (objectui `588d11d2b`)
-- **patch** — A settings page loads its namespace when the namespace changes, and no longer when the translation function does (objectui#13254). The load used to re-run on every new translation… (objectui `9a3bcfc7f`)
-- **patch** — Opening a collapsed form section keeps keyboard focus on its header the first time too (objectui#13246). (objectui `ff128e366`)
-- **patch** — A grid grouped by a `date` or `datetime` field heads each group with the value drawn the way the field's cell draws it, not with the stored string (objectui#13234). (objectui `84b43263f`)
-- **patch** — The record's approval history names each step by the label the flow author wrote (objectui#13248). A history row used to draw the step's node id, prettified: "Manager Review" for… (objectui `12cde0492`)
-- **patch** — The Audit Log's Action, Actor, From and To filters are named by their visible labels (objectui#13224). (objectui `039e0b972`)
-- **patch** — The console's own menus ask before they drop a page's unsaved edits (objectui#13231). (objectui `e077b8a4e`)
-- **patch** — `Combobox` finds an option by its visible label, not only by its value (objectui#13229). (objectui `3650d9455`)
-- **patch** — The console's remediation overlay offers "Stop impersonating" on an impersonation session (objectui#13208). The overlay covers the whole console, the impersonation banner included… (objectui `e70c21d43`)
-- **patch** — A collapsible form section header can be opened from the keyboard (objectui#13220). (objectui `4b92c5a91`)
-- **patch** — Flow designer: the cycle, repeated-connection and missing-boundary-host Problems, the canvas edge hover titles, the simulator's node list, edge rows and steps, and the nested insp… (objectui `80843d05c`)
-- **patch** — A list view fetches a shown column's declared `dependsOn` siblings, so a faced column can read them off the row (objectui#13214). (objectui `f22b980cb`)
-- **patch** — Setup → Datasources draws a refusal as a refusal (objectui#13207). (objectui `878a4af38`)
-- **patch** — Import wizard: the result screen now says what happened. An import that imported every row still shows the green check and "Import Complete"; one that imported some rows but skipp… (objectui `7c05f210d`)
-- **patch** — The approvals Recall button no longer calls a submitter's own recall an override, and an admin recalling someone else's request is told what a recall does (objectui#13225). `appro… (objectui `06be3969e`)
-- **patch** — Each per-type group heading in the Studio's Pending changes sheet names its type the way the summary line does, in the user's locale: two flow drafts head their group "2 flows" in… (objectui `24001c181`)
-- **patch** — Record drawers open on their first field (objectui#13219), as record dialogs do since objectui#13130. (objectui `ab4e4d7d2`)
-- **patch** — The flow designer names a node by its label in three more places (objectui#13200), through the same rule as objectui#13143: the author's label, else the palette label of the node'… (objectui `599a8a54b`)
-- **patch** — fix(app-shell): the save warning has a sentence of its own for a value a stored expression replaced (objectui `8803ca54a`)
-- **patch** — An empty `object-grid` no longer tells the user to adjust filters they never set (objectui#13142). (objectui `f0300beff`)
-- **patch** — Settings pages: when the server refuses a save field by field, the toast now names the refused settings by the labels their rows show, for example "1 setting needs attention: Defa… (objectui `52907d7d4`)
-- **patch** — Studio's list-view panel names the view by its display name, not its machine name (objectui#13151). (objectui `d4d9ddbec`)
-- **patch** — The draft preview bar promises sample data on publish only when publishing will load some (objectui#13140). (objectui `42bd92650`)
-- **patch** — An admin overriding an approval REASSIGN is now told what a reassign does (objectui#13164). The approvals action bar gave every override lever the decision sentence — "it finalise… (objectui `58798eca8`)
-- **patch** — Keyboard access to the console: a "Skip to content" link, and record dialogs that open on their first field (objectui#13130). (objectui `9eb14790a`)
-- **patch** — The Audit Log's Action filter offers the actions the server declares for `sys_audit_log.action` (objectui#13175). (objectui `da307ef4a`)
-- **patch** — The Studio's Pending changes summary agrees each type with its count: "2 items · 2 flows", "4 items · 1 flow · 3 views". Known metadata types read through a new `preview.changes.t… (objectui `f466b0ac7`)
-- **patch** — Studio form designer: each field card's type tag now names the type the way the type picker and the field inspector do (objectui#13148). A Picklist field read "Status SELECT" on t… (objectui `d72204599`)
-- **patch** — Create View dialog: a view type the object cannot support now says why on its card ("This object has no field that can serve as …" or "This object exposes no dataset to chart."),… (objectui `5c12dcd16`)
-- **patch** — fix(app-shell): Setup › Packages draws its Actions column only over rows that carry an action, and the package sheet wraps its values at words (objectui#13166) (objectui `8d4e45376`)
-- **patch** — zh-CN: Studio home "新建软件包(可写 base)" now reads "新建软件包(可写基座)", and the duplicate-package tooltip no longer quotes "duplicate base" in English — both reuse the table's existing term… (objectui `2af42d9e5`)
-- **patch** — The draft preview bar's count follows a runtime view-config Save or Publish without a reload (objectui#13139). (objectui `7d497de38`)
-- **patch** — Home's pending-drafts row names the package its drafts share by the name Studio shows for it, not by its id (objectui#13136). (objectui `2782e6d24`)
-- **patch** — A settings page asks before it lets unsaved edits go (objectui#13163). (objectui `7a5f26715`)
-- **patch** — The flow designer names a node by the name its author gave it, not by its id, in three places that used to print the id (objectui#13143): (objectui `19b4f6490`)
-- **patch** — Studio's field pickers no longer offer the columns the platform injects and hides (objectui#13152). (objectui `5666e1934`)
-- **patch** — Lookup quick-picker: the preview line under each option no longer cuts a badge to its first letter (objectui#13123). A column drawn as a badge (a `select`, `status`, `radio`, `mul… (objectui `5129caad9`)
-- **patch** — The calendar header's date title no longer clips at phone widths (objectui#13149). At 390px the day view's title was cut by the container edge ("Tuesday, October 20, 2") with no e… (objectui `3cec0901f`)
-- **patch** — Studio's flow header no longer clips its meta mid-word (objectui#13135). With the Properties panel open, "Trigger: … · Status: … · Run as: …" was cut off at the edge of the header… (objectui `05e3f34b4`)
-- **patch** — The Pending changes sheet's field rows lead with the field's label, and no longer list a column the platform injected and hides as a change the author made (objectui#13125). (objectui `e81a37cfd`)
-- **patch** — Studio's Data rail points at its own "+" when a package has no objects (objectui#13127). The rail's empty sentence read "No objects yet — create one below to start", but the rail… (objectui `c9cb89662`)
-- **patch** — Advanced chart axis tick labels now use the theme's muted foreground instead of recharts' default `#666`, so they stay readable on dark cards (objectui#13129). Every axis the adva… (objectui `5115c1f08`)
-- **patch** — Flow canvas: a connection that skips a node is drawn around it (objectui#13144). (objectui `768960e38`)
-- **patch** — Studio's New package dialog no longer calls the package "project-scoped" (objectui#13126). ObjectStack renamed project to environment (ObjectStack ADR-0006), and the dialog is not… (objectui `7e6282225`)
-- **patch** — The Audit Log's Object filter is picked from the served objects instead of typed (objectui#13165). (objectui `c209c933d`)
-- **patch** — At phone width the console's floating create button no longer covers the last card of a list (objectui#13134). (objectui `aaa1296f9`)
-- **patch** — A finished import no longer says "Review data before importing." under its result (objectui#13124). (objectui `9840695f6`)
-- **patch** — The Packages sheet offers no write action on a read-only package (objectui#13157). It gated its actions on the package's `scope` being `system` or `cloud`, so a project-scoped or… (objectui `f7aac4ce7`)
-- **patch** — The inbox header's breakdown line agrees in number: "1 notification" and "1 pending approval", no longer "1 notifications" and "1 pending approvals" (objectui#13133). (objectui `74e37aa2c`)
-- **patch** — Setup › Packaged automation no longer shows "On" for a packaged flow that does not run (objectui#13159). When the engine reports a flow as enabled but not armed — the automation s… (objectui `673a71c39`)
-- **patch** — On phone-width screens, each record card in a grid is now titled by the object's declared `nameField`, not by the view's first column (objectui#13150). (objectui `5a8d40f53`)
-- **patch** — A settings value can go back to unset, and an override back to the inherited value (objectui#13145). (objectui `961b69f97`)
-- **patch** — A lookup's quick-picker takes the choice after a search, and a single-select lookup's trigger shows the picked record (objectui#13162). (objectui `f48a4d554`)
-- **patch** — Flow designer: adding a branch to a Decision no longer overwrites the condition of its existing connection (objectui#13138). (objectui `186b78d8f`)
-
-**In this console build, declared nowhere** — objectui merged 3 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(console): teach a navigation visible that both validators accept (#13281) (objectui `7f6a2dd95`)
-- _(no changeset)_ chore(release): exit Changesets pre mode (`next`), so the next version pass renders objectui's 18.0.0 GA (#13111) (objectui `cbc312d78`)
-- _(no changeset)_ perf(scripts): the body-dialect census skips the pinned @objectstack build and parses only what can hold a counted node (objectui#13210) (#13250) (objectui `e53763630`)
-
-objectui range: `616df6598b42...cf153eb2e535`
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index b00aed5edea..90c44306173 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -75,7 +75,7 @@ docker run -p 8080:8080 \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET \
-e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:18.0.0
+ ghcr.io/objectstack-ai/objectstack:18.1.0
```
(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
@@ -93,7 +93,7 @@ docker run -p 8080:8080 \
-e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:18.0.0
+ ghcr.io/objectstack-ai/objectstack:18.1.0
```
Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly
@@ -150,7 +150,7 @@ COPY . .
RUN npx os build # → dist/objectstack.json
# ── Runtime: the official ObjectStack runtime image ──────────────────
-FROM ghcr.io/objectstack-ai/objectstack:18.0.0
+FROM ghcr.io/objectstack-ai/objectstack:18.1.0
COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json
```
@@ -173,7 +173,7 @@ FROM node:22-slim
RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \
&& export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \
&& test "$(npm --version)" = "11.21.0" \
- && npm install -g @objectstack/cli@18.0.0 "pg@^8.0.0" "mysql2@^3.0.0" \
+ && npm install -g @objectstack/cli@18.1.0 "pg@^8.0.0" "mysql2@^3.0.0" \
&& npm cache clean --force \
&& rm -rf /tmp/os-installer-npm
diff --git a/content/docs/releases/index.mdx b/content/docs/releases/index.mdx
index 9b929fdab04..80de358a50a 100644
--- a/content/docs/releases/index.mdx
+++ b/content/docs/releases/index.mdx
@@ -18,7 +18,7 @@ migration steps, then covers new capabilities and notable fixes.
## Versions
-- [v18](/docs/releases/v18) — The line that carries ADR-0131, total organization ownership with no NULL `organization_id`; its breaking changes landed in stages through the `18.0.0-next.N` prereleases on npm's `next` tag, each with its own changeset and migration, and its GA release, 18.0.0, publishes to npm's `latest` tag and moves it from 17.x to 18 (current series: 18.0.0, released 2026-10-19).
+- [v18](/docs/releases/v18) — The line that carries ADR-0131, total organization ownership with no NULL `organization_id`; its breaking changes landed in stages through the `18.0.0-next.N` prereleases on npm's `next` tag, each with its own changeset and migration, and its GA release, 18.0.0, publishes to npm's `latest` tag and moves it from 17.x to 18 (current series: 18.1.0, released 2026-10-21).
- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.7.0, released 2026-10-06).
- [v16.0.0](/docs/releases/v16) — One org identifier (`organizationId`) across hooks and actions, quorum + per-group sign-off (会签) approvals with metadata-declared decision actions, time-relative automations, filtered roll-ups, strict dashboard widgets, an identity-scoped MCP stdio transport, and a platform-wide enforce-or-remove sweep that makes dead metadata loud; 16.1 adds a `requires` capability-provider preflight, two more dashboard build gates, and `runAs:'user'` automations that run with the triggering user's real grants (final release: 16.1.0).
- [v15.0.0](/docs/releases/v15) — Explain record access layer by layer, a docked AI workspace in the Console, project-ready Gantt charts, and phone sign-in; 15.1 adds permission-following attachments, no-code third-party connectors, dashboard-wide filters, pinyin search, and whole-record inline editing — with materially safer multi-tenant and write-path defaults (final release: 15.1.1).
diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx
index 07f9199202f..3ce617db5a2 100644
--- a/content/docs/upgrading.mdx
+++ b/content/docs/upgrading.mdx
@@ -52,7 +52,7 @@ The official image is `ghcr.io/objectstack-ai/objectstack`, and its tags mirror
```bash
# docker-compose.yml, or your orchestrator's manifest
-image: ghcr.io/objectstack-ai/objectstack:18.0.0
+image: ghcr.io/objectstack-ai/objectstack:18.1.0
```
On a host running the artifact directly under systemd, the same move is a file
diff --git a/docker/README.md b/docker/README.md
index fe6188382bd..f8cb15d25dc 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`.
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)):
```dockerfile
-FROM ghcr.io/objectstack-ai/objectstack:18.0.0
+FROM ghcr.io/objectstack-ai/objectstack:18.1.0
COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json
```
@@ -40,7 +40,7 @@ docker run -p 8080:8080 \
-v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
- ghcr.io/objectstack-ai/objectstack:18.0.0
+ ghcr.io/objectstack-ai/objectstack:18.1.0
```
`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
@@ -72,7 +72,7 @@ for a `file:…` path — one box only, wrong for multi-node) and MongoDB
(`libsql://…` / Turso). Add one by extending the image:
```dockerfile
-FROM ghcr.io/objectstack-ai/objectstack:18.0.0
+FROM ghcr.io/objectstack-ai/objectstack:18.1.0
USER root
RUN npm install -g tedious
USER node
@@ -100,5 +100,5 @@ reverse-proxy / multi-node guidance:
## Local build of this image
```bash
-docker build -t objectstack:dev --build-arg OS_CLI_VERSION=18.0.0 docker/
+docker build -t objectstack:dev --build-arg OS_CLI_VERSION=18.1.0 docker/
```
diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md
index 5e05c5740c4..22620ee06a2 100644
--- a/examples/app-crm/CHANGELOG.md
+++ b/examples/app-crm/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/example-crm
+## 4.0.101
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-i18n@18.1.0
+
## 4.0.100
### Patch Changes
diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json
index eda88107a5f..ea3d1675d11 100644
--- a/examples/app-crm/package.json
+++ b/examples/app-crm/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-crm",
- "version": "4.0.100",
+ "version": "4.0.101",
"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 d16f22c85a1..3245859d122 100644
--- a/examples/app-multi-package/CHANGELOG.md
+++ b/examples/app-multi-package/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/example-multi-package
+## 0.0.8
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 0.0.7
### Patch Changes
diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json
index 71881a6d408..90187017772 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.7",
+ "version": "0.0.8",
"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 97b9a59f011..e86d3b2d790 100644
--- a/examples/app-showcase/CHANGELOG.md
+++ b/examples/app-showcase/CHANGELOG.md
@@ -1,5 +1,60 @@
# @objectstack/example-showcase
+## 0.3.23
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [78ab647]
+- Updated dependencies [6b9e6df]
+- Updated dependencies [e7befa7]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [55d7aab]
+- Updated dependencies [eee65af]
+- Updated dependencies [0715fac]
+- Updated dependencies [ea08ce5]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [b811c73]
+- Updated dependencies [5d27d32]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+- Updated dependencies [ae58b26]
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/driver-sql@18.1.0
+ - @objectstack/cloud-connection@18.1.0
+ - @objectstack/service-datasource@18.1.0
+ - @objectstack/connector-mcp@18.1.0
+ - @objectstack/connector-openapi@18.1.0
+ - @objectstack/connector-rest@18.1.0
+ - @objectstack/connector-slack@18.1.0
+ - @objectstack/service-i18n@18.1.0
+ - @objectstack/service-print@18.1.0
+
## 0.3.22
### Patch Changes
diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json
index a3bd0532d35..d943dffa9de 100644
--- a/examples/app-showcase/package.json
+++ b/examples/app-showcase/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-showcase",
- "version": "0.3.22",
+ "version": "0.3.23",
"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 8b36fdf8da5..884f466bcbc 100644
--- a/examples/app-todo/CHANGELOG.md
+++ b/examples/app-todo/CHANGELOG.md
@@ -1,5 +1,56 @@
# @objectstack/example-todo
+## 4.0.101
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [72518d2]
+- Updated dependencies [e7befa7]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [55d7aab]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/client@18.1.0
+ - @objectstack/driver-sqlite-wasm@18.1.0
+ - @objectstack/mcp@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/knowledge-memory@18.1.0
+ - @objectstack/service-i18n@18.1.0
+ - @objectstack/service-knowledge@18.1.0
+
## 4.0.100
### Patch Changes
diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json
index 2c83d9fc95f..b2b7fce1d0b 100644
--- a/examples/app-todo/package.json
+++ b/examples/app-todo/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/example-todo",
- "version": "4.0.100",
+ "version": "4.0.101",
"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 3d6beb76f07..4f104ec7917 100644
--- a/examples/embed-objectql/CHANGELOG.md
+++ b/examples/embed-objectql/CHANGELOG.md
@@ -1,5 +1,47 @@
# @objectstack/example-embed-objectql
+## 0.0.41
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [72518d2]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/driver-memory@18.1.0
+
## 0.0.40
### Patch Changes
diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json
index 2dd5143fe77..d9acb4b8d93 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.40",
+ "version": "0.0.41",
"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 d268e683927..a69ad9c81c5 100644
--- a/packages/adapters/hono/CHANGELOG.md
+++ b/packages/adapters/hono/CHANGELOG.md
@@ -1,5 +1,37 @@
# @objectstack/hono
+## 18.1.0
+
+### Minor Changes
+
+- 02e6d73: fix(cors)!: every CORS door reads one admission rule; a loopback origin beside a list is admitted on a development boot only; an all-host pattern is refused with credentials, as `'*'` is
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** — an admission narrowing at three doors, shipped as `minor` under the launch-window convention for accept-set narrowings. The governing text is `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an explicit origin list. It finishes what the `HonoServerPlugin` default change that `18.0.0` shipped (#24738) began.
+
+ - **One rule.** `@objectstack/plugin-hono-server` exports `resolveCorsAdmission`, and both transport doors read it: `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) and `@objectstack/hono`'s `createHonoApp`. One configuration now gets one answer at both. That one rule shared by two published doors is why the public surface grows: `resolveCorsAdmission` with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`, `isAllHostOriginPattern`, `OriginMatcherOptions`, and an optional second parameter on `createOriginMatcher`.
+ - **`createHonoApp`, before.** With no origin list (no `cors.origin` option and no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests, and a configured `'*'` with credentials on was accepted the same way. **Now** it answers as `HonoServerPlugin` does: no list admits no cross-origin caller (a loopback origin excepted, on a development boot), and `'*'` with credentials on is refused when the app is created.
+ - **`createHonoApp`'s `cors.origin` as one comma-separated string, before.** A `cors.origin` written as one comma-separated string of exact origins (`'https://a.example.com,https://b.example.com'`) admitted no origin. **Now** each listed origin is admitted, as `OS_CORS_ORIGIN` written the same way already was: a widening only toward what the operator listed, which the option's TSDoc already promised ("Comma-separated string or array").
+ - **Loopback beside a list, before.** A configured list also admitted every loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port), in every mode, at both doors that used the pattern matcher. **Now** that happens on a development boot only (`NODE_ENV` exactly `development`, read through `isDevelopmentBoot` from `@objectstack/core`). Elsewhere a list admits what it names; a list that names `http://localhost:*` still admits it. One door gains on a development boot: `createHonoApp` with a list of exact origins never admitted a loopback origin, and now does there, as `HonoServerPlugin` already did. `matchOriginPattern` now matches the pattern and nothing else, and `createOriginMatcher` takes an optional `{ developmentBoot }`, read from `NODE_ENV` when the matcher is created if it is not passed, so a consumer that applies the matcher outside the Hono pipeline answers as the doors do on the same boot.
+ - **All-host patterns, before.** Only a literal `'*'` was refused with credentials. **Now** a pattern whose host part is the bare `*` (`https://*`, `*://*`, `http://*:*`; `isAllHostOriginPattern`) is refused with credentials at both transport doors exactly as `'*'` is, and the message names the entry.
+ - **Auth's trusted origins, before.** `@objectstack/plugin-auth` tested the whole `OS_CORS_ORIGIN` value against `'*'` and then split it, so a `'*'` entry among real origins, or an all-host pattern, entered better-auth's trusted origins. **Now** each entry is judged on its own, and an entry that admits every host never enters the trusted set.
+ - **Unchanged.** Same-origin requests need no CORS answer. An exact origin or a pattern that names part of the host (`https://*.example.com`) is admitted as before, credentials included; the one exception is the comma-separated `cors.origin` string above, which now admits what it lists. `'*'` with credentials off is still served. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Headers, methods and max-age are unchanged. `OS_TRUSTED_ORIGINS` still admits nothing to CORS.
+
+ **What an operator does.** A host composing `createHonoApp` for a cross-origin front-end names its origins, in `OS_CORS_ORIGIN` or the `cors.origin` option. A deployment that reached the server from a loopback origin outside development names that origin in its list. A list holding `'*'` or an all-host pattern while credentials are on either names its origins or sets `OS_CORS_CREDENTIALS=false`.
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [0091d84]
+- Updated dependencies [02e6d73]
+- Updated dependencies [4cef6e2]
+ - @objectstack/runtime@18.1.0
+ - @objectstack/plugin-hono-server@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json
index a2558ed1712..c237a337704 100644
--- a/packages/adapters/hono/package.json
+++ b/packages/adapters/hono/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/hono",
- "version": "18.0.0",
+ "version": "18.1.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 0d8145c3eb2..984625a0815 100644
--- a/packages/apps/account/CHANGELOG.md
+++ b/packages/apps/account/CHANGELOG.md
@@ -1,5 +1,48 @@
# @objectstack/account
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json
index e435201e10d..a618201d911 100644
--- a/packages/apps/account/package.json
+++ b/packages/apps/account/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/account",
- "version": "18.0.0",
+ "version": "18.1.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 788bdcf1f26..84222595510 100644
--- a/packages/apps/setup/CHANGELOG.md
+++ b/packages/apps/setup/CHANGELOG.md
@@ -1,5 +1,48 @@
# @objectstack/setup
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json
index e204af26154..bbdc55a16ec 100644
--- a/packages/apps/setup/package.json
+++ b/packages/apps/setup/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/setup",
- "version": "18.0.0",
+ "version": "18.1.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 a4fbc32021d..243a399eb39 100644
--- a/packages/apps/studio/CHANGELOG.md
+++ b/packages/apps/studio/CHANGELOG.md
@@ -1,5 +1,48 @@
# @objectstack/studio
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json
index 24fa8defd91..7e122fa5de4 100644
--- a/packages/apps/studio/package.json
+++ b/packages/apps/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/studio",
- "version": "18.0.0",
+ "version": "18.1.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 8462f4d4b43..64e53b1f5dd 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,354 @@
# @objectstack/cli
+## 18.1.0
+
+### Minor Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 19484af: fix(cli): `os migrate apply` on an empty database writes the organization-ownership birth marker, so the fresh install's first `os serve` starts
+
+ Clause-②: yes
+
+ `os serve` (and so `os dev` and `os start`) serves a database this release created itself, recognised by the birth marker in `sys_migration` (row `adr-0131-organization-ownership`), or one the ownership ceremony certified. `os migrate apply` holds its schema DDL back until the operator confirms, so its boot saw no table created and wrote no marker, and the tables it then created carried none. A deploy script that ran `os migrate apply` before the first `os serve` was refused as a database "17.x or an 18.0 pre-release created", and sent through the upgrade ceremony.
+
+ - **Now.** When `os migrate apply` creates the database from empty, it writes the birth marker right after it creates the tables, and the first `os serve` starts. The log line is the one a serving boot prints: `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, …)`.
+ - **Never on a database that was not empty.** The decision is the serving boot's own: the database counts as created from empty only when none of the tables this release declares existed before the apply. A database an earlier release created gains no marker from `os migrate apply`, even when the apply creates a table in it, and is still refused until the ceremony runs: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`.
+ - **A failed write fails the command.** If the tables are created and the marker cannot be written, `os migrate apply` exits `1` and names the ceremony, which certifies such a database.
+ - **Unchanged.** A database that already carries a marker keeps it as it is. `os migrate plan` writes nothing. On a Turso remote (`libsql://`) primary, `os migrate apply` creates no tables (its schema sync is skipped there), so the first serving boot creates them and writes the marker, as before.
+- 3d8960c: feat(cli,plugin-audit): `os migrate audit-mail-bodies` also masks the mail bodies in the audit copies already moved to the `archive` datasource
+
+ Clause-②: yes (widening)
+
+ **Re-run `os migrate audit-mail-bodies --apply` on a deployment with an archive datasource.** The 18.0.0 command masked the hot audit tables only.
+
+ - **What was left in clear.** `sys_audit_log` declares `lifecycle.archive: { after: '90d', to: 'archive', keep: '7y' }`. Past the hot window, the lifecycle Archiver copies its rows to the datasource named `archive` as they are, then deletes them hot. A `sys_email` / `sys_identity_email` copy archived before the audit writer masked the mail body still holds `body_text` / `body_html` in clear there, password-reset, verification, magic-link and invitation links included, for as long as the archive keeps it. The engine never routes `sys_audit_log` to the archive, so the hot run could not reach those rows.
+ - **What changed.** After the hot run, the command reads the archive target each copy table declares (`sys_audit_log` only; `sys_activity` declares none) and runs the same mask, in the same dry-run or `--apply` mode, through that datasource's driver. Each archived row keeps every other column, `updated_at` included. The run is idempotent and its report is counts only. A hot copy masked first is archived masked, so one run covers both the copies already archived and the ones still to be.
+ - **Which archives it reaches.** The command now connects the managed datasources the stack config declares, through the same connect service `os serve` uses. An archive datasource is declared with `autoConnect: true`, since nothing routes objects to it. It is NOT examined, and the run reports `not-connected` and exits `1` rather than reporting a clean archive, in two cases. In the first, the archive is declared but this boot did not connect it; the entry carries the connect status and reason. In the second, the archive is a runtime datasource created in Setup → Datasources, which `os serve` connects and this command does not; declare it in the config's `datasources` and re-run.
+ - **What `none` means.** No datasource of that name is declared and connected on this boot, none was attempted, and no active runtime datasource row names one. That is all it checks. An archive a host registers in code outside the stack config is not reached and reads `none`. To mask it, declare it in the stack config's `datasources` with `autoConnect: true`.
+ - **The command's boot can now fail on a declared datasource.** A bound, mapped or boot-critical declared datasource that cannot connect now fails the command's boot with exit `1`, as it fails `os serve`'s. `OS_ALLOW_DRIVER_CONNECT_FAILURE` is the same escape.
+ - **Run it with the server stopped**, or re-run it once and read 0 to mask in both halves. A live server's Archiver can move a row between the hot run and the archive run.
+ - **New in the `--json` document:** an `archive` array, one entry per declared target: `{ datasource, objects, status, report?, connection? }`, with `status` one of `examined`, `none`, `not-connected` or `unknown`. The `report` key and its shape are unchanged.
+ - **New exports from `@objectstack/plugin-audit`:** `migrateArchivedMailLogBodyCopies(driver, logger, options)`, the runner over a datasource's driver, and an `objects` option on both runners, which limits a run to the named copy tables. Types `MailLogBodyArchiveDriver`, `MailLogBodyCopyObject` and `MailLogBodyMigrationOptions`.
+- 63085c3: feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing.
+
+ ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match.
+
+ **What moves for consumers, FROM → TO.**
+
+ - **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it.
+ - **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles.
+ - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged.
+ - **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.
+
+ **Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`.
+- 62bc129: fix(cli)!: `os migrate apply` exits `1` when the confirmation it needs is not given, as every other `os migrate` subcommand with a `--yes` gate already does
+
+ In a non-interactive shell (stdin is not a terminal) or under `--json`, `os migrate apply` without `--yes` applies nothing and prints `⚠ Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.` (under `--json`, its `confirmation_required` document). It exited `0` there, so a deploy script or an agent reading the exit status took a run that applied nothing for an applied one, and the next boot still reported the drift. Every other `os migrate` subcommand that writes behind `--yes` — `comment-reactions --apply`, `meta --stored --apply`, `recorded-by --apply`, `resume --run` and the rest — already exits `1` in that situation, as `organization-ownership --apply` does without `--backup-taken`. `apply` now answers the same way, in both faces. The warning and the `--json` document are unchanged.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (an exit status narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** `os migrate apply` (with or without `--allow-destructive`) on a database with changes to apply, without `--yes`, with stdin not a terminal or with `--json`: nothing applied, exit `0`.
+ - **TO:** nothing applied, exit `1`. The printed warning and the `--json` document (`message: 'confirmation_required'`, `hint: 'pass --yes'`) are unchanged.
+ - **Unchanged:** answering `n` at the `[y/N]` prompt in a terminal still prints `Aborted — no changes made.` and exits `0`; a run with `--yes` applies and exits `0`; a database with nothing to apply exits `0` without asking.
+ - **The fix for a script:** a script that means to apply passes `--yes` (`os migrate apply --yes`, or `os migrate apply --allow-destructive --yes`); one that only wants to know what would change runs `os migrate plan`.
+- 157ebd0: feat(cli, objectql): `os db orphans` — report the detail rows whose required master-detail master does not exist, and delete them only behind `--delete`, after a pre-delete export
+
+ Clause-②: yes (widening)
+
+ **What was wrong.** A system-context write is exempt from the write-time reference check, so an elevated writer could store a master-detail id that names no master. Such a row's access derives from that master, so every user door answered 404 for it, the administrator's `GET` and `DELETE` included, and a list query left it out. The integrity audit reported the row and, by design, never rewrites it. No door existed to remove it, so it stayed until someone edited the database by hand.
+
+ **`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it.
+
+ - **The default run is a report** that writes no row or table and creates no file. It boots read-only, as `os migrate plan` does. It lists each detail row whose required `master_detail` master does not exist, with its object, record id, field and missing master id. It also lists the deeper detail rows that the delete's cascade removes with it. `--json` prints one document, `{ mode: 'report', plan }`.
+ - **What is never deleted, and is said.** A reference whose target object could not be checked (not registered, no driver, or a failed read) is counted as `undetermined`. Unknown is not missing. A dangling reference on the same objects through a field that is not a required master-detail is listed in `otherDangling`. An orphan whose cascade could not be read is listed as `withheld`, with the reason. A scan bounded by `--max-rows` (default 100000) names every object it could not read in full.
+ - **`--delete` requires `--export `.** The export holds every record the run deletes, as the driver stores it. It is written owner-only, is created rather than overwritten, and is read back before the first delete. If it cannot be written or does not hold every record, the run refuses with nothing deleted. Just before deleting, the run reads the report again and skips an orphan whose master now exists, or whose cascade now reaches a record the export does not hold. Each delete is the engine's own system-context delete, so the master-detail's cascade removes the deeper details. A `restrict` dependent refuses the delete, and a cascade on one datasource is one unit of work, so nothing of that orphan is removed. A `set_null` reference to a deleted row is cleared. A roll-up on the missing master cannot be recomputed, and the run notes this rather than failing. Each delete is judged by reading its records back.
+ - **Refusals**, each exit 1 with nothing deleted, and the code under `--json` as `error`: `export_required`, `export_exists`, `export_failed`, `export_incomplete`, `confirmation_required` (`--json` without `--yes`), `boot_failed`, `no_engine` and `scan_failed`.
+
+ **`@objectstack/objectql`: `ObjectQL.inspectCascadeDelete(object, id)` (new method).** A read-only method that returns every record a by-id `delete()` of that record removes, root first. It is the cascade set the delete collects for itself, read through the same walk. It writes nothing, runs no hook, and files no reference-check elevation record, because no delete happens. It reads as the system with no organization. `restrict` and `set_null` relations are not listed, because neither removes a record. A dependents probe that fails propagates.
+
+ Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged.
+- 30d21f6: feat(spec): a `page:tabs` item label gains a translation key, `pages..components..items..label`
+
+ Clause-②: yes (widening)
+
+ - **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
+ - **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
+ - **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
+ - **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
+ - **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
+- eddffc3: feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language
+
+ Clause-②: yes (widening)
+
+ - **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
+ - **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
+ - **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
+ - **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
+ - **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
+
+### Patch Changes
+
+- feb09db: fix(cli): `os dev`'s in-place upgrade line no longer says every refused database is "still in the v17 organization-ownership shape"
+
+ Clause-②: no (printed text of `os dev` only: no exit code, refusal, recovered boot or IPC message moves)
+
+ When the organization-ownership boot guard refuses the server `os dev` starts, `os dev` copies the development database and upgrades it in place. The line it printed first said the database was "still in the v17 organization-ownership shape", for every refusal. That was wrong for a database an 18.0 pre-release created, and for every refusal that is not about the database's origin at all: a marker that is unverified, unreadable or from an older shape generation, or a read or write the guard could not make.
+
+ The line now names no origin. It points at the guard's own refusal, printed just above it, which says what the guard found, and it says that `os dev` upgrades the database in place, keeping a copy first. The verdict line printed after the upgrade still gives the copy's path. Which databases `os dev` upgrades, how it upgrades them, and what a bare `os serve` prints are unchanged.
+- 5f11cec: fix(cli): `os migrate plan` names the organization-ownership ceremony, not `--allow-destructive`, for the `organization_id` drops `os migrate apply` refuses (#24925)
+
+ Clause-②: no
+
+ On a database the organization-ownership ceremony has not verified, such as a 17.x one before the ceremony, `os migrate apply --allow-destructive` refuses every run that would drop an `organization_id` column, and drops nothing. No flag stands that refusal down. `os migrate plan` still listed each of those columns as `"os migrate apply --allow-destructive" to drop it.`, closed with `(add --allow-destructive for drops / tightenings)`, and did not mention the ceremony.
+
+ - **Now.** Each such drop is still listed and counted as destructive drift. Its line names the ceremony as its remedy: `… (orphaned) — a drop the organization-ownership ceremony owns, and it has not verified on this database: the ceremony drops it itself (see below).`
+ - **The footer.** It offers `--allow-destructive` for nothing while such a drop is listed, because `apply` refuses the whole run. It still offers `os migrate apply` when there is other work that command performs without the flag. Then it names the ceremony's two commands: `os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`. It also counts the other destructive changes that wait for the ceremony.
+ - **Unchanged.** On a database whose ceremony has verified, or one with no `organization_id` drop, the plan reads as before, byte for byte, and on the second it reads no marker. The `--json` document, the exit code and `os migrate apply` are unchanged.
+
+ **What to do.** Nothing. On a 17.x database, follow the plan's footer: run the ceremony, then re-run `os migrate plan`.
+- 1a1f5c3: fix(cli): `os migrate plan` and `os migrate apply` name the command each held drop waits for, never `--allow-destructive`, for every drop `apply` refuses (#24958)
+
+ Clause-②: no
+
+ `os migrate apply --allow-destructive` refuses the whole run, whatever flag is passed, while it would drop one of three columns before its precondition holds: `sys_account.issuer` while its retirement pre-flight finds `(provider_id, account_id)` keys held by more than one row (or cannot read `sys_account`), `sys_comment.reactions` while no verified run of `os migrate comment-reactions --apply` is recorded, and an `organization_id` column while the organization-ownership ceremony has not verified. Yet `os migrate plan` offered `--allow-destructive` for the first two, and `os migrate apply` without the flag listed all three with `"os migrate apply --allow-destructive" to drop it.` and closed with `N destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).` and `Skipped N destructive change(s) (re-run with --allow-destructive to include them).` That re-run was refused, exit 1.
+
+ - **Now.** Each such drop is still listed and counted as destructive drift, in both commands. Its line names what it waits for: the ceremony, `os migrate comment-reactions`, or the retirement pre-flight `os migrate account-issuer`.
+ - **The footer.** One line per gate that holds a drop names that gate's commands and says `--allow-destructive` is refused until they have run. `os migrate plan` offers `--allow-destructive` for nothing while a drop is held. `os migrate apply` prints `N destructive change(s) will be SKIPPED, and a re-run with --allow-destructive cannot include them yet:` before those lines, and `Skipped N destructive change(s); a re-run with --allow-destructive cannot include them yet (see above).` after the apply.
+ - **Unchanged.** A database with no held drop prints what it printed before, byte for byte, in both commands. The `--json` documents, the exit codes and every refusal of `os migrate apply --allow-destructive` (its rule and its text) are unchanged.
+
+ **What to do.** Nothing. Follow the footer: run the command each held drop names, then re-run `os migrate plan`.
+- 4bf079c: fix(plugin-auth): `os migrate apply`'s `sys_account.issuer` pre-flight report names each colliding row's stored issuer instead of `(none)`
+
+ Clause-②: yes (widening)
+
+ `probeAccountIdentityCollisions` read `sys_account` through the engine's `find`. The engine answers declared columns only, and the shipped `sys_account` no longer declares `issuer`, so the report `os migrate apply --allow-destructive` prints when it refuses the `sys_account.issuer` drop labelled every colliding row's issuer `(none)`, in the human report and in the `--json` document's `preflight.collisions[].issuers` alike. `os migrate account-issuer` read the same database through the driver and printed the stored values. The issuers are what an operator needs to decide which colliding row survives.
+
+ - **`@objectstack/plugin-auth`.** When its source offers the storage door (`getDriverForObject`, as the `objectql` engine both CLI doors pass does), the probe reads `sys_account` through the driver the engine routes that name to, and through nothing else: a missing driver is refused, never answered from the engine's `find`. A source that offers `find` alone is still read as handed. A new option, `onTableAbsent`, reads a missing `sys_account` table as no rows and reports that it did. Without it, a missing table is refused like any other failed read.
+ - **Widened public surface (`@objectstack/plugin-auth`).** The package entry re-exports the pre-flight module, so it now publishes two new types, `AccountTableStorage` (`getDriverForObject`) and `AccountTableDriver`, and a new option, `AccountIdentityPreflightOptions.onTableAbsent`. `probeAccountIdentityCollisions` now accepts `AccountTableStorage | AccountIdentityReadEngine` as its source. Every earlier caller still type-checks, and nothing narrows.
+ - **`@objectstack/cli`.** `os migrate account-issuer` reads through the probe's reader instead of its own, so it and `os migrate apply` read one table one way. Its output is unchanged.
+
+ **Unchanged.** The collision verdict (`ok`, the colliding keys, row ids, users, counts), the `apply` refusal and its exit code. A database whose `sys_account` has no `issuer` column reports each row as `(none)`, as before.
+- 03966ce: fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
+
+ `@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
+
+ Clause-②: no (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
+
+ - **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
+ **TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
+ - **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
+ **TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
+ - **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
+ **TO:** it answers `400` with `error: 'invalid_grant'`.
+ - **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
+ **TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
+ - **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
+ **TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
+ - **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
+ **TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
+ - **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
+ **TO:** the same `401` also clears the stale session cookies.
+ - **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
+ - **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
+ - **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
+ - **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
+ - **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [8cfe1f9]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [9a06f0b]
+- Updated dependencies [f90ea67]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [c2d4072]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [abd5acd]
+- Updated dependencies [6b9e6df]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [73a2b52]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [6440cf1]
+- Updated dependencies [72518d2]
+- Updated dependencies [e5b3de0]
+- Updated dependencies [88cc145]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [28ff8a9]
+- Updated dependencies [55d7aab]
+- Updated dependencies [98c3d00]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [eee65af]
+- Updated dependencies [3fbef41]
+- Updated dependencies [fcb40ad]
+- Updated dependencies [ba69b16]
+- Updated dependencies [865d2c8]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [3b3830c]
+- Updated dependencies [644e4b2]
+- Updated dependencies [3d8960c]
+- Updated dependencies [0715fac]
+- Updated dependencies [ea08ce5]
+- Updated dependencies [19e5338]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0603bfa]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [b811c73]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [f802213]
+- Updated dependencies [86d2360]
+- Updated dependencies [688052e]
+- Updated dependencies [5d27d32]
+- Updated dependencies [6b656bf]
+- Updated dependencies [0a3ce08]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [9532e27]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [58cd58f]
+- Updated dependencies [58cd58f]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [d68aab6]
+- Updated dependencies [aaee084]
+- Updated dependencies [c0ae356]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [31f0f27]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [b289090]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [f46ef4d]
+- Updated dependencies [3782b2d]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [a39389e]
+- Updated dependencies [2e242bb]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4f97aeb]
+- Updated dependencies [db9c02a]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+- Updated dependencies [ae58b26]
+- Updated dependencies [7adfaec]
+ - @objectstack/core@18.1.0
+ - @objectstack/plugin-security@18.1.0
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/plugin-sharing@18.1.0
+ - @objectstack/plugin-approvals@18.1.0
+ - @objectstack/metadata-protocol@18.1.0
+ - @objectstack/driver-sql@18.1.0
+ - @objectstack/driver-turso@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/lint@18.1.0
+ - @objectstack/plugin-webhooks@18.1.0
+ - @objectstack/service-automation@18.1.0
+ - @objectstack/service-messaging@18.1.0
+ - @objectstack/service-storage@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/service-settings@18.1.0
+ - @objectstack/cloud-connection@18.1.0
+ - @objectstack/plugin-email@18.1.0
+ - @objectstack/service-sms@18.1.0
+ - @objectstack/service-datasource@18.1.0
+ - @objectstack/plugin-audit@18.1.0
+ - @objectstack/plugin-hono-server@18.1.0
+ - @objectstack/rest@18.1.0
+ - @objectstack/console@18.1.0
+ - @objectstack/client@18.1.0
+ - @objectstack/driver-memory@18.1.0
+ - @objectstack/driver-mongodb@18.1.0
+ - @objectstack/driver-sqlite-wasm@18.1.0
+ - @objectstack/mcp@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/plugin-pinyin-search@18.1.0
+ - @objectstack/service-analytics@18.1.0
+ - @objectstack/service-cache@18.1.0
+ - @objectstack/service-job@18.1.0
+ - @objectstack/service-mailbox@18.1.0
+ - @objectstack/service-package@18.1.0
+ - @objectstack/service-queue@18.1.0
+ - @objectstack/service-realtime@18.1.0
+ - @objectstack/trigger-api@18.1.0
+ - @objectstack/trigger-record-change@18.1.0
+ - @objectstack/trigger-schedule@18.1.0
+ - @objectstack/verify@18.1.0
+ - @objectstack/account@18.1.0
+ - @objectstack/setup@18.1.0
+ - create-objectstack@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index bd216edb052..40c06227d42 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/cli",
- "version": "18.0.0",
+ "version": "18.1.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 c46a267c3d0..cecaf4efa50 100644
--- a/packages/client-react/CHANGELOG.md
+++ b/packages/client-react/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/client-react
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/client@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/client-react/package.json b/packages/client-react/package.json
index 5d876b9295c..9d2266fbacd 100644
--- a/packages/client-react/package.json
+++ b/packages/client-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/client-react",
- "version": "18.0.0",
+ "version": "18.1.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 bc39354849b..be877b9f865 100644
--- a/packages/client/CHANGELOG.md
+++ b/packages/client/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/client
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/client/package.json b/packages/client/package.json
index 2a5eccbee3a..0ed5ddf783f 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/client",
- "version": "18.0.0",
+ "version": "18.1.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 2fae036df6c..19776367510 100644
--- a/packages/cloud-connection/CHANGELOG.md
+++ b/packages/cloud-connection/CHANGELOG.md
@@ -1,5 +1,88 @@
# @objectstack/cloud-connection
+## 18.1.0
+
+### Minor Changes
+
+- 78ab647: feat(cloud-connection): `GET /api/v1/runtime/config` serves the Branding settings' name, logo and colour, falling back to `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`
+
+ Clause-②: no (the served `branding` block keeps its shape and no exported type changes; only where its values come from moves)
+
+ `RuntimeConfigPlugin` reads the `branding` settings namespace on every request, so Setup → Branding changes the console and the sign-in page on their next load:
+
+ - `branding.productName` ← `branding.workspace_name`
+ - `branding.logoUrl` ← `branding.logo_url`
+ - `branding.brandColor` ← `branding.accent_color`
+
+ A value somebody set wins (saved in Setup, or pinned by the settings layer's own `OS_BRANDING_*` variable). A key nobody set falls back exactly as before: the host's constructor option, then `OS_PRODUCT_NAME` / `OS_LOGO_URL` / `OS_BRAND_COLOR`, then the built-in default. `productShortName`, `pwaDescription` and `pwaThemeColor` follow the name and colour actually served, unless configured themselves. `brandColor` keeps its meaning: the console's browser and installed-app theme colour, beside each app's own `AppBranding` colours.
+
+ The door reads the settings only when the settings service lists the `branding` namespace. `@objectstack/service-settings` withholds it under the `group` and `isolated` tenancy postures, so on a walled deployment no tenant data reaches this anonymous door and the deployment's `OS_*` branding is served, as before. A settings read that fails serves the deployment's branding and logs one warning; the door never answers 500 for it.
+- ae58b26: fix(cloud-connection)!: a runtime holding the environment service key is platform-managed, so it registers no Cloud Connection Setup page and refuses to disconnect; everywhere else the Cloud Connection entry moves to Setup's Integrations group
+
+ A runtime whose control-plane credential is the environment service key (`controlPlaneApiKey`, or `OS_CLOUD_API_KEY`, which the cloud sets on the environments it hosts) is connected by the platform, not by anyone on the runtime. It still registered the Cloud Connection page in Setup's Apps group, which read "Connected" and offered Disconnect. Disconnecting there cleared the runtime's stored device credential while the service key stayed in use, so the page went on reading "Connected", and the control plane was asked to revoke a connection the platform had made.
+
+ Such a runtime now registers neither the page nor its navigation entry, and says so once at boot. A request to disconnect it is refused with `409` and `RESOURCE_CONFLICT`, after the sign-in and Setup Access checks and before anything is revoked or cleared. The message says the platform manages the connection.
+
+ A self-hosted or local runtime, connected through the device-code flow, keeps the page and its disconnect unchanged. Its Cloud Connection entry moves from the Apps group to the Integrations group, after Webhooks, Datasources and Connect an Agent. That group requires `manage_platform_settings`, so a user who holds Setup Access without it, such as an organization administrator, no longer sees the entry. The connect and disconnect requests still take Setup Access alone.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (on a runtime holding the environment service key, a disconnect request it carried out is now refused, and the Cloud Connection page and its navigation entry are no longer served), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **Unchanged:** a runtime without the service key, apart from the entry's group. The connection status answer is unchanged on every runtime.
+ - **Nothing to rewrite:** a cloud-hosted environment's connection is the platform's to manage. A self-hosted server leaves `OS_CLOUD_API_KEY` unset, as the deployment guide says; one that sets it is platform-managed by this rule.
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [72518d2]
+- Updated dependencies [e7befa7]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [55d7aab]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-protocol@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json
index a1a21f8ad83..504862eec89 100644
--- a/packages/cloud-connection/package.json
+++ b/packages/cloud-connection/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/cloud-connection",
- "version": "18.0.0",
+ "version": "18.1.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-imap/CHANGELOG.md b/packages/connectors/connector-imap/CHANGELOG.md
index 17fffbbc760..4b0fc2c4d5c 100644
--- a/packages/connectors/connector-imap/CHANGELOG.md
+++ b/packages/connectors/connector-imap/CHANGELOG.md
@@ -1,5 +1,9 @@
# @objectstack/connector-imap
+## 18.1.0
+
+No changes in this release.
+
## 18.0.0
### Minor Changes
diff --git a/packages/connectors/connector-imap/package.json b/packages/connectors/connector-imap/package.json
index 8353d99e546..9fa0bfac0f2 100644
--- a/packages/connectors/connector-imap/package.json
+++ b/packages/connectors/connector-imap/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-imap",
- "version": "18.0.0",
+ "version": "18.1.0",
"license": "Apache-2.0",
"description": "IMAP connector for ObjectStack — a read-only IMAP4rev1 session (EXAMINE, UID FETCH with BODY.PEEK) and the per-user-shaped mail account and credential abstraction the mailbox sync connects through.",
"main": "dist/index.js",
diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md
index 0dc7b2b38c0..edbb2ebe24e 100644
--- a/packages/connectors/connector-mcp/CHANGELOG.md
+++ b/packages/connectors/connector-mcp/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/connector-mcp
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json
index ed511818347..ecbb5333f64 100644
--- a/packages/connectors/connector-mcp/package.json
+++ b/packages/connectors/connector-mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-mcp",
- "version": "18.0.0",
+ "version": "18.1.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 2e7aaca6936..4d82a4d9953 100644
--- a/packages/connectors/connector-openapi/CHANGELOG.md
+++ b/packages/connectors/connector-openapi/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/connector-openapi
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json
index 1c45c756c1d..a27c404612d 100644
--- a/packages/connectors/connector-openapi/package.json
+++ b/packages/connectors/connector-openapi/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-openapi",
- "version": "18.0.0",
+ "version": "18.1.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 ca51bc9a80a..968412d819e 100644
--- a/packages/connectors/connector-rest/CHANGELOG.md
+++ b/packages/connectors/connector-rest/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/connector-rest
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json
index 444a5772a16..cd688a0533b 100644
--- a/packages/connectors/connector-rest/package.json
+++ b/packages/connectors/connector-rest/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-rest",
- "version": "18.0.0",
+ "version": "18.1.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 9f040d90ee5..625c826b0b4 100644
--- a/packages/connectors/connector-slack/CHANGELOG.md
+++ b/packages/connectors/connector-slack/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/connector-slack
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json
index 9aedc91ae36..cc3401140b0 100644
--- a/packages/connectors/connector-slack/package.json
+++ b/packages/connectors/connector-slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/connector-slack",
- "version": "18.0.0",
+ "version": "18.1.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 bc829b7b3d4..dac7978c9c8 100644
--- a/packages/console/CHANGELOG.md
+++ b/packages/console/CHANGELOG.md
@@ -1,5 +1,87 @@
# @objectstack/console
+## 18.1.0
+
+### Minor Changes
+
+- 7adfaec: Console (objectui) refreshed to `cf153eb2e535`. Frontend changes in this range:
+
+ Derived from the changesets objectui declared over the range — 65 releasing of 66 changesets added across 69 non-merge commits; omitted: 1 release-nothing changeset, 3 commits carrying no changeset (they ship no package code).
+
+ - **minor** — `NavigationItemSchema` now refuses a navigation entry whose `visible` reads `data` but which declares no `dataSource`, as `@objectstack/spec`'s `NavigationItemSchema` already does… (objectui `8556295f0`)
+ - **patch** — The permission-set editor no longer keeps the clone notice once the copy is held or saved (objectui#13272). (objectui `cf153eb2e`)
+ - **patch** — Setup › Packaged automation: the clone result now offers the next steps (objectui#13270). (objectui `5235d6d57`)
+ - **patch** — The README's "Setup half of an item" now says what objectstack's shipped organization administrator set does on the assignment objects: it creates and removes rows on `sys_user_po… (objectui `b001d211d`)
+ - **patch** — The package edit dialog (Studio / Setup) now edits a package's `dependencies`. The row is no longer locked once the package exists, and saving sends it to `PATCH /api/v1/packages/… (objectui `36b70c055`)
+ - **patch** — A row action's `defaultFromRow` param backed by a relation is seeded with the related record's id again, not the whole record (objectui#13271). (objectui `8e649da7d`)
+ - **patch** — A `kind:'react'` page keeps its own state when its host re-renders (objectui#13268). The page was recompiled whenever it received a new schema object, and the console hands it a f… (objectui `8cd4db497`)
+ - **patch** — A record drawer stays open on its first open (objectui#13238). On the first open in a page session, the drawer's body is drawn for the first time once the record is read, and its… (objectui `a29f61576`)
+ - **patch** — Settings pages refuse to save while a visible required setting is empty (objectui#13239). (objectui `1ca759a16`)
+ - **patch** — The command palette, the inbox and the AI entry ask before they drop a page's unsaved edits (objectui#13253). (objectui `c8833c5b8`)
+ - **patch** — A category x axis rotates its labels, and cuts them at 12 characters, only when the measured band cannot hold them (objectui#13241). A full-width bar chart now draws a name such a… (objectui `9a4ddb2a9`)
+ - **patch** — The language menu asks before a switch drops a page's unsaved edits (objectui#13254). The console shell remounts every page on a language change, so switching the language on a pa… (objectui `588d11d2b`)
+ - **patch** — A settings page loads its namespace when the namespace changes, and no longer when the translation function does (objectui#13254). The load used to re-run on every new translation… (objectui `9a3bcfc7f`)
+ - **patch** — Opening a collapsed form section keeps keyboard focus on its header the first time too (objectui#13246). (objectui `ff128e366`)
+ - **patch** — A grid grouped by a `date` or `datetime` field heads each group with the value drawn the way the field's cell draws it, not with the stored string (objectui#13234). (objectui `84b43263f`)
+ - **patch** — The record's approval history names each step by the label the flow author wrote (objectui#13248). A history row used to draw the step's node id, prettified: "Manager Review" for… (objectui `12cde0492`)
+ - **patch** — The Audit Log's Action, Actor, From and To filters are named by their visible labels (objectui#13224). (objectui `039e0b972`)
+ - **patch** — The console's own menus ask before they drop a page's unsaved edits (objectui#13231). (objectui `e077b8a4e`)
+ - **patch** — `Combobox` finds an option by its visible label, not only by its value (objectui#13229). (objectui `3650d9455`)
+ - **patch** — The console's remediation overlay offers "Stop impersonating" on an impersonation session (objectui#13208). The overlay covers the whole console, the impersonation banner included… (objectui `e70c21d43`)
+ - **patch** — A collapsible form section header can be opened from the keyboard (objectui#13220). (objectui `4b92c5a91`)
+ - **patch** — Flow designer: the cycle, repeated-connection and missing-boundary-host Problems, the canvas edge hover titles, the simulator's node list, edge rows and steps, and the nested insp… (objectui `80843d05c`)
+ - **patch** — A list view fetches a shown column's declared `dependsOn` siblings, so a faced column can read them off the row (objectui#13214). (objectui `f22b980cb`)
+ - **patch** — Setup → Datasources draws a refusal as a refusal (objectui#13207). (objectui `878a4af38`)
+ - **patch** — Import wizard: the result screen now says what happened. An import that imported every row still shows the green check and "Import Complete"; one that imported some rows but skipp… (objectui `7c05f210d`)
+ - **patch** — The approvals Recall button no longer calls a submitter's own recall an override, and an admin recalling someone else's request is told what a recall does (objectui#13225). `appro… (objectui `06be3969e`)
+ - **patch** — Each per-type group heading in the Studio's Pending changes sheet names its type the way the summary line does, in the user's locale: two flow drafts head their group "2 flows" in… (objectui `24001c181`)
+ - **patch** — Record drawers open on their first field (objectui#13219), as record dialogs do since objectui#13130. (objectui `ab4e4d7d2`)
+ - **patch** — The flow designer names a node by its label in three more places (objectui#13200), through the same rule as objectui#13143: the author's label, else the palette label of the node'… (objectui `599a8a54b`)
+ - **patch** — fix(app-shell): the save warning has a sentence of its own for a value a stored expression replaced (objectui `8803ca54a`)
+ - **patch** — An empty `object-grid` no longer tells the user to adjust filters they never set (objectui#13142). (objectui `f0300beff`)
+ - **patch** — Settings pages: when the server refuses a save field by field, the toast now names the refused settings by the labels their rows show, for example "1 setting needs attention: Defa… (objectui `52907d7d4`)
+ - **patch** — Studio's list-view panel names the view by its display name, not its machine name (objectui#13151). (objectui `d4d9ddbec`)
+ - **patch** — The draft preview bar promises sample data on publish only when publishing will load some (objectui#13140). (objectui `42bd92650`)
+ - **patch** — An admin overriding an approval REASSIGN is now told what a reassign does (objectui#13164). The approvals action bar gave every override lever the decision sentence — "it finalise… (objectui `58798eca8`)
+ - **patch** — Keyboard access to the console: a "Skip to content" link, and record dialogs that open on their first field (objectui#13130). (objectui `9eb14790a`)
+ - **patch** — The Audit Log's Action filter offers the actions the server declares for `sys_audit_log.action` (objectui#13175). (objectui `da307ef4a`)
+ - **patch** — The Studio's Pending changes summary agrees each type with its count: "2 items · 2 flows", "4 items · 1 flow · 3 views". Known metadata types read through a new `preview.changes.t… (objectui `f466b0ac7`)
+ - **patch** — Studio form designer: each field card's type tag now names the type the way the type picker and the field inspector do (objectui#13148). A Picklist field read "Status SELECT" on t… (objectui `d72204599`)
+ - **patch** — Create View dialog: a view type the object cannot support now says why on its card ("This object has no field that can serve as …" or "This object exposes no dataset to chart."),… (objectui `5c12dcd16`)
+ - **patch** — fix(app-shell): Setup › Packages draws its Actions column only over rows that carry an action, and the package sheet wraps its values at words (objectui#13166) (objectui `8d4e45376`)
+ - **patch** — zh-CN: Studio home "新建软件包(可写 base)" now reads "新建软件包(可写基座)", and the duplicate-package tooltip no longer quotes "duplicate base" in English — both reuse the table's existing term… (objectui `2af42d9e5`)
+ - **patch** — The draft preview bar's count follows a runtime view-config Save or Publish without a reload (objectui#13139). (objectui `7d497de38`)
+ - **patch** — Home's pending-drafts row names the package its drafts share by the name Studio shows for it, not by its id (objectui#13136). (objectui `2782e6d24`)
+ - **patch** — A settings page asks before it lets unsaved edits go (objectui#13163). (objectui `7a5f26715`)
+ - **patch** — The flow designer names a node by the name its author gave it, not by its id, in three places that used to print the id (objectui#13143): (objectui `19b4f6490`)
+ - **patch** — Studio's field pickers no longer offer the columns the platform injects and hides (objectui#13152). (objectui `5666e1934`)
+ - **patch** — Lookup quick-picker: the preview line under each option no longer cuts a badge to its first letter (objectui#13123). A column drawn as a badge (a `select`, `status`, `radio`, `mul… (objectui `5129caad9`)
+ - **patch** — The calendar header's date title no longer clips at phone widths (objectui#13149). At 390px the day view's title was cut by the container edge ("Tuesday, October 20, 2") with no e… (objectui `3cec0901f`)
+ - **patch** — Studio's flow header no longer clips its meta mid-word (objectui#13135). With the Properties panel open, "Trigger: … · Status: … · Run as: …" was cut off at the edge of the header… (objectui `05e3f34b4`)
+ - **patch** — The Pending changes sheet's field rows lead with the field's label, and no longer list a column the platform injected and hides as a change the author made (objectui#13125). (objectui `e81a37cfd`)
+ - **patch** — Studio's Data rail points at its own "+" when a package has no objects (objectui#13127). The rail's empty sentence read "No objects yet — create one below to start", but the rail… (objectui `c9cb89662`)
+ - **patch** — Advanced chart axis tick labels now use the theme's muted foreground instead of recharts' default `#666`, so they stay readable on dark cards (objectui#13129). Every axis the adva… (objectui `5115c1f08`)
+ - **patch** — Flow canvas: a connection that skips a node is drawn around it (objectui#13144). (objectui `768960e38`)
+ - **patch** — Studio's New package dialog no longer calls the package "project-scoped" (objectui#13126). ObjectStack renamed project to environment (ObjectStack ADR-0006), and the dialog is not… (objectui `7e6282225`)
+ - **patch** — The Audit Log's Object filter is picked from the served objects instead of typed (objectui#13165). (objectui `c209c933d`)
+ - **patch** — At phone width the console's floating create button no longer covers the last card of a list (objectui#13134). (objectui `aaa1296f9`)
+ - **patch** — A finished import no longer says "Review data before importing." under its result (objectui#13124). (objectui `9840695f6`)
+ - **patch** — The Packages sheet offers no write action on a read-only package (objectui#13157). It gated its actions on the package's `scope` being `system` or `cloud`, so a project-scoped or… (objectui `f7aac4ce7`)
+ - **patch** — The inbox header's breakdown line agrees in number: "1 notification" and "1 pending approval", no longer "1 notifications" and "1 pending approvals" (objectui#13133). (objectui `74e37aa2c`)
+ - **patch** — Setup › Packaged automation no longer shows "On" for a packaged flow that does not run (objectui#13159). When the engine reports a flow as enabled but not armed — the automation s… (objectui `673a71c39`)
+ - **patch** — On phone-width screens, each record card in a grid is now titled by the object's declared `nameField`, not by the view's first column (objectui#13150). (objectui `5a8d40f53`)
+ - **patch** — A settings value can go back to unset, and an override back to the inherited value (objectui#13145). (objectui `961b69f97`)
+ - **patch** — A lookup's quick-picker takes the choice after a search, and a single-select lookup's trigger shows the picked record (objectui#13162). (objectui `f48a4d554`)
+ - **patch** — Flow designer: adding a branch to a Decision no longer overwrites the condition of its existing connection (objectui#13138). (objectui `186b78d8f`)
+
+ **In this console build, declared nowhere** — objectui merged 3 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(console): teach a navigation visible that both validators accept (#13281) (objectui `7f6a2dd95`)
+ - _(no changeset)_ chore(release): exit Changesets pre mode (`next`), so the next version pass renders objectui's 18.0.0 GA (#13111) (objectui `cbc312d78`)
+ - _(no changeset)_ perf(scripts): the body-dialect census skips the pinned @objectstack build and parses only what can hold a counted node (objectui#13210) (#13250) (objectui `e53763630`)
+
+ objectui range: `616df6598b42...cf153eb2e535`
+
## 18.0.0
### Major Changes
diff --git a/packages/console/package.json b/packages/console/package.json
index d3a3ca03c10..5a48c5af370 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/console",
- "version": "18.0.0",
+ "version": "18.1.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 5e522a3bce8..dd7f7dd8d71 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,69 @@
# @objectstack/core
+## 18.1.0
+
+### Minor Changes
+
+- 94842ba: feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.
+
+ - **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
+ - **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
+ - **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
+ - **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
+ - **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
+ - **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.
+
+ **What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
+
+### Patch Changes
+
+- 644e4b2: fix(core): the authorization prelude's grant read names the columns it decides on, so no formula on `sys_user_permission_set` is computed in every request a grant holder makes
+
+ Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves)
+
+ `resolveAuthzContext` reads the principal's `sys_user_permission_set` grants on every authenticated request. That read named no columns, so the engine computed every formula field the grant table declares for it, the record title included, though the resolver reads none of them. A title that reads through a lookup (the person's name, one hop through `user_id`) would have cost each request by a principal holding a direct grant one more `sys_user` read and one more round trip: 9 reads in 5 round trips instead of 8 in 4. The read now names exactly the four columns the resolver decides on: `permission_set`, `organization_id`, `valid_from` and `valid_until`. The resolved authorization context is unchanged for every grant state (active, expired, not yet valid, organization-owned, deployment-wide, and the Default Organization's `admin_full_access` grant), and a grant holder's prelude stays at 8 reads in 4 round trips whatever display formula the grant table declares.
+- dfe60e3: fix(core): the import resolves a lookup cell by the target object's stored title field
+
+ Clause-②: no
+
+ - **Before.** A lookup cell was matched against the target by its id, then by the lookup's own `displayField`, then by a fixed list of keys (`name`, `title`, `label`, `full_name`, `email`, `username`). The target object's declared title field was never asked. So a cell that named the record by its title, such as `Wren` for a target whose title is `nickname`, answered `reference_not_found`, although the list export writes lookup cells by that same title.
+ - **Now.** After the id and the lookup's `displayField`, the import asks the target's title field, resolved by ADR-0079's one rule (`resolveDisplayField`: `nameField`, then the deprecated `displayNameField` alias, then the derived title). It is read from the same object declaration the import already reads to check the target's exposure, so no extra metadata read is made. Two records that share the title answer `reference_ambiguous`, like the import's other ambiguous matches. The cell is never linked to the first hit.
+ - **A formula title is not matched.** A `formula` field has no stored column, and the engine refuses a filter over it. The import no longer sends that query, for the title or for any other candidate the target declares as a formula, so a cell against a formula title still answers `reference_not_found`. How a formula title should be matched is still open.
+ - **Unchanged.** The fixed fallback keys and their order. The exposure check still runs first: a target whose declared exposure does not serve `list` answers `reference_not_found` for every cell and is never read.
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 60761ac3b38..f4773b46154 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/core",
- "version": "18.0.0",
+ "version": "18.1.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 d7590368bb8..3359c5ca80b 100644
--- a/packages/create-objectstack/CHANGELOG.md
+++ b/packages/create-objectstack/CHANGELOG.md
@@ -1,5 +1,9 @@
# create-objectstack
+## 18.1.0
+
+No changes in this release.
+
## 18.0.0
### Minor Changes
diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json
index 33ee2bfe132..3a078037a98 100644
--- a/packages/create-objectstack/package.json
+++ b/packages/create-objectstack/package.json
@@ -1,6 +1,6 @@
{
"name": "create-objectstack",
- "version": "18.0.0",
+ "version": "18.1.0",
"description": "Create a new ObjectStack project — npx create-objectstack",
"bin": {
"create-objectstack": "./bin/create-objectstack.js"
diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md
index 4ed7040943e..7e1db5b0fdd 100644
--- a/packages/drivers/driver-memory/CHANGELOG.md
+++ b/packages/drivers/driver-memory/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/driver-memory
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json
index de9d6c57db5..d84d77921e6 100644
--- a/packages/drivers/driver-memory/package.json
+++ b/packages/drivers/driver-memory/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-memory",
- "version": "18.0.0",
+ "version": "18.1.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 9da81b4be28..94cc9e7353c 100644
--- a/packages/drivers/driver-mongodb/CHANGELOG.md
+++ b/packages/drivers/driver-mongodb/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/driver-mongodb
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json
index 16d7cf505fe..73cbdf9d764 100644
--- a/packages/drivers/driver-mongodb/package.json
+++ b/packages/drivers/driver-mongodb/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-mongodb",
- "version": "18.0.0",
+ "version": "18.1.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 b4d6cbd7a87..2442d2d110b 100644
--- a/packages/drivers/driver-sql/CHANGELOG.md
+++ b/packages/drivers/driver-sql/CHANGELOG.md
@@ -1,5 +1,161 @@
# @objectstack/driver-sql
+## 18.1.0
+
+### Minor Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 74fcec1: fix(driver-sql)!: on PostgreSQL, `introspectSchema` reads each table's columns in the table's own schema, and refuses a `search_path` that reaches one table name in two schemas instead of silently dropping one
+
+ `introspectSchema` lists the base tables of every schema in `current_schemas(false)` and keys the answer by bare table name. Two things went wrong when a connection's `search_path` named more than one schema (`ALTER DATABASE … SET search_path = public, sales`). First, each table's columns came from knex `columnInfo()` on the bare name, which reads only the first schema on the path, so a table that lives only in a later schema (`sales.orders`) was listed with no columns. `listRemoteTables` answered it `columnCount: 0`, and a draft built from it had no fields. Second, a same-named table in a later schema (`sales.customers` behind `public.customers`) was dropped without a word, because one key cannot hold two tables. The PostgreSQL branch now reads every table's columns in its own schema. When one bare name is reached in more than one schema, it refuses the whole introspection with `RESOURCE_CONFLICT` / `409`, naming each colliding table with its schemas in `search_path` order. A connection that reaches one schema, the default `public` deployment included, answers exactly what it answered before. The SQLite and MySQL branches are unchanged.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (introspection's accept set narrows on PostgreSQL), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** a PostgreSQL connection whose `search_path` reached a table name in two schemas got a successful `introspectSchema()` answer holding one of the two tables (the one in the earliest schema on the path), and so did every caller built on it: `listRemoteTables`, object drafting, catalog refresh and the external datasource's connection test.
+ - **TO:** the same call throws `RESOURCE_CONFLICT` (`status: 409`), naming each colliding table and its schemas, for example `'customers' ('public', 'sales')`. Those callers surface that error.
+ - **Also changed (a wrong answer made right, no accept set moved):** a table that lives only in a later schema on the path now reports its own columns where it reported none.
+ - **Unchanged:** a connection that reaches one schema, the default `public` deployment included. One schema cannot hold two same-named tables, so it is never refused, and its answer is byte-identical to before.
+ - **Fix:** point the connection at one schema (the datasource's `schema` setting sets its `search_path`), or rename one of the two tables on the remote database.
+- 7f99616: fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)
+
+ `SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `.corrupt-`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.
+
+ `connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '': connect failed — `. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
+
+ - **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `.corrupt-` and an empty database was written in its place.
+ - **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
+ - **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
+- 4d99158: fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of opening an empty in-memory database over it and reporting it connected
+
+ Clause-②: no (narrowing)
+
+
+
+ `sqliteAbsentFile: 'empty-in-memory'` is the open mode a read-only boot uses (`os migrate plan`, and the dry runs that boot read-only): an absent SQLite file is opened as an empty in-memory database, so nothing is created on disk. Its judgement of "absent" was `existsSync(path) === false`, which is also the answer for a path that cannot exist: a path under a regular file (`ENOTDIR`), a directory the process may not search (`EACCES`). So `os migrate plan` printed `datasource '': connected` for a declared datasource on such a path and planned `create_table` into it, while the boot that writes refused the same path.
+
+ **BREAKING** (the read-only boot's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** under `'empty-in-memory'`, a SQLite path that can be neither opened nor created read as absent. The driver opened `:memory:`, and the datasource's connect succeeded.
+ - **TO:** `resolveSqliteAbsentFileTarget`, and so the `SqlDriver` constructor, throws `SqliteDatabaseFileError` naming the file and the operating system's answer: `ENOTDIR` (a path under a regular file), `EACCES` (a directory the process may not write or search), `EROFS` (a read-only mount), or `ENOENT` (a directory whose name a dangling symbolic link holds). The datasource's connect fails with it, as the boot that writes already failed, so `os migrate plan` reports the datasource failed instead of planning into it. The judgement still writes nothing: no directory and no file is created to find out.
+ - **Unchanged:** an absent file in a directory that exists, or that could be made, still opens as an empty in-memory database and leaves the disk untouched. An existing file opens as before. The default mode (`'create'`) is untouched, and so is every server dialect: the option is now applied to SQLite clients only, as documented (a `pg` driver given it with a connection string used to have that string rewritten to `:memory:`).
+ - **Fix:** if a read-only boot now refuses a SQLite datasource, correct its `filename`: the refusal names the path and the reason.
+- d6a5fa3: fix(driver-sql): a SQLite database whose header SQLite cannot read fails `connect()` at once, naming the file, and a dangling symbolic link as the database's directory is refused on the boot that writes, as the read-only boot already refused it (#25019)
+
+ Three more SQLite files a declared datasource cannot use, each now classified by the file's own driver at connect:
+
+ - **A database whose header or schema SQLite cannot read** (a file truncated inside its 100-byte header, or short of the pages its schema names). The connection's first statement, `PRAGMA auto_vacuum`, answers `SQLITE_CORRUPT`; `SqlDriver.connect()` logged it as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and resolved over a file no statement could read. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) then stepped down to wasm SQLite, whose open renamed the file to `.corrupt-` and wrote an empty database in its place, under a warning that blamed the native addon. `connect()` now refuses it with a `SqliteDatabaseFileError` (`code: 'SQLITE_CORRUPT'`, `filename`, a message naming the file) in every mode, before anything is written, and `resolveSqliteDriver` re-throws it instead of stepping down. A database damaged deeper inside still connects: its first statement answers, and the damage surfaces on the read that reaches it, as before.
+ - **A dangling symbolic link as the database's directory, on the boot that writes.** `connect()` took `mkdir`'s `EEXIST` on the link for a concurrent creator and resolved; the first statement then failed with a code-less "directory does not exist", and a development boot stepped down to wasm SQLite under the same false native-addon warning. `connect()` now refuses it with `SqliteDatabaseFileError` (`code: 'ENOENT'`), naming the file and the link, which is the error the read-only boot (`sqliteAbsentFile: 'empty-in-memory'`) gives for the same path.
+ - **A dangling symbolic link as the database file itself, on the read-only boot.** When the directory its chain of links ends in does not exist, the read-only boot read the file absent and reported the datasource connected, while the boot that writes refuses the same path with `SQLITE_CANTOPEN`. The read-only boot now refuses it too (`code: 'ENOENT'`), naming the link's target. When that directory exists, both boots behave as before: the read-only boot opens an empty in-memory database, and the boot that writes creates the file through the link.
+
+ A real database file, an absent file in a directory that exists or can be made, and a native addon that cannot load over a real database keep today's behaviour.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (`SqlDriver.connect()`'s accept set and the read-only boot's narrow): a connect that resolved over a SQLite database whose header SQLite cannot read, or over a path whose directory is a dangling symbolic link, now rejects; a read-only boot that read a dangling file link absent although its target's directory does not exist now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
+
+ - **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot a damaged file was renamed to `.corrupt-` and replaced with an empty database, and a dangling-link directory stepped down to wasm SQLite under a warning that blamed the native addon. The read-only boot reported a dangling file link connected.
+ - **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_CORRUPT'` for the damaged file and `code: 'ENOENT'` for the dangling-link directory, naming the file (and the link); the read-only boot rejects the dangling file link with `code: 'ENOENT'`, naming its target. The file is untouched.
+ - **Fix:** restore the database from a backup, or move the damaged file aside yourself (an absent file is created empty on the next connect); point the dangling link at a directory that exists, or point the datasource at a real path.
+
+### Patch Changes
+
+- 5e9b100: feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database
+
+ Clause-②: yes (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** accept-set narrowing at the database (ADR-0131 D1: "There is no nullable tenant column anywhere"). It changes what a data writer can store, not what a metadata author can write, so it ships as `patch` with this banner.
+
+ - **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns.
+ - **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed: .organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict.
+ - **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. On a database that has not taken the ceremony, a table this release creates (a newly rotated shard, or a cold table a newly configured lifecycle tier creates) is NOT NULL beside nullable siblings: an organization-less row the lifecycle mover would carry into it is refused there, and stays where it is until the ceremony attributes it. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
+ - **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly.
+
+ **The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`.
+- eee65af: fix(driver-sql): `update()` no longer writes its `updated_at` stamp into the caller's `data`, so webhook deliveries for record updates are stored again on PostgreSQL and MySQL
+
+ Clause-②: no
+
+ - **What was lost.** On PostgreSQL and MySQL, every webhook delivery for a record update was lost. `SqlDriver.update()` assigned its `updated_at` stamp onto the patch object the caller passed in. ObjectQL publishes that same object as the update event's `changes`. On those two dialects the stamp is a knex `Raw` (`CURRENT_TIMESTAMP`) that holds the database client, its pool and a timer. The webhook outbox could not serialise the event (`Converting circular structure to JSON`), so no `sys_http_delivery` row was written. The record write still answered 200, and the webhook still read active. Every other reader of the update event's `changes`, and of the patch after the driver call, saw the same `Raw`. On SQLite the stamp is an ISO string, so the delivery was stored, but the caller's patch and the event's `changes` still carried an `updated_at` the caller never wrote.
+ - **What changed.** `update()`, including its rotated-table path, stamps `updated_at` into a fresh object, as `updateMany()` already did. The caller's `data` is left exactly as it was passed.
+ - **Unchanged.** The stored `updated_at` value on every dialect, `preserveAudit` imports, tables without an `updated_at` column, `upsert()` (it already stamps a copy of its input), and every export and option.
+- 4e2c42f: fix(driver-sql): a SQLite `connect()` that refuses its file closes the connection it opened before it rejects
+
+ Clause-②: no (resource release only: no response, status, error, outcome or export moves)
+ Metadata-protocol: none
+
+ - **What it was.** `SqlDriver.connect()` refuses a declared SQLite file its first statement cannot use (`SqliteDatabaseFileError`: `SQLITE_CANTOPEN`, `SQLITE_NOTADB`, `SQLITE_CORRUPT`). The file was opened to give that answer, and the pool kept the better-sqlite3 connection after the rejection. For a damaged file whose header says WAL, SQLite kept its `-shm` and an empty `-wal` beside the file until something called `disconnect()`. A datasource left unconnected on the degraded path is never disconnected, so it kept them for the life of the process.
+ - **What changed.** When a SQLite `connect()` rejects, it destroys its pool and puts a fresh, empty one in its place, then rethrows the same error: same class, code, message and `cause`. Nothing is left open, and the `-shm` / `-wal` siblings are gone when the rejection arrives. A later `connect()` on the same instance opens the file again: it gives the same refusal while the file is bad, and connects once the file is repaired. A release that itself fails is logged at `warn`, and the original refusal is still the error thrown.
+ - **Unchanged.** Which files are refused, the codes and the messages. Server dialects (`pg`, `mysql`) are untouched: their `connect()` rejects only when no connection could be made, so there is nothing to release. `SqliteWasmDriver` and the local mode of `TursoDriver` inherit the release through `super.connect()`.
+ - **What to do.** Nothing.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json
index bb4d0645406..2eacc3edca1 100644
--- a/packages/drivers/driver-sql/package.json
+++ b/packages/drivers/driver-sql/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-sql",
- "version": "18.0.0",
+ "version": "18.1.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 9de930bf329..6e89455455f 100644
--- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
+++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md
@@ -1,5 +1,47 @@
# @objectstack/driver-sqlite-wasm
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [eee65af]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/driver-sql@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json
index 0a4ed3d829c..94b827e7bbb 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": "18.0.0",
+ "version": "18.1.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 013b5f0e682..ee35a5e3380 100644
--- a/packages/drivers/driver-turso/CHANGELOG.md
+++ b/packages/drivers/driver-turso/CHANGELOG.md
@@ -1,5 +1,116 @@
# @objectstack/driver-turso
+## 18.1.0
+
+### Minor Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 28c9a2c: fix(spec,driver-turso)!: a turso datasource that declares `encryptionKey` on a local file or an embedded replica is refused where it is written and when the driver is built, instead of being accepted and written in plaintext
+
+ Clause-②: no (narrowing) — the accept set of the `turso` `datasource.config` contract, of the published `TursoConfigSchema` mirror and of `new TursoDriver()` narrows by one key beside a `file:` url or `:memory:`, in every mode. No key is added, removed or renamed, and no exported symbol moves. `@objectstack/service-datasource` is graded `patch`: the TSDoc of its exported `TursoDriverConfigInput.encryptionKey` no longer promises an encrypted file, and nothing else in it changes.
+
+ `encryptionKey` was declared as an "AES-256 encryption key for the local database file", and nothing applied it. The turso driver reads and writes a local or replica file through better-sqlite3, which has no encryption codec (`PRAGMA key` is a silent no-op there). Measured on the driver source with a test key: a local datasource with `encryptionKey` constructed, connected and wrote its rows, and plain better-sqlite3, opening the file read-only with no key, read them back. An embedded replica fared no better: only its sync client was handed the key, after the local engine had already written the file in plaintext, and a file that client encrypted is one the local engine cannot open. An operator who declared the key got an unencrypted file and no signal.
+
+ **BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). A non-empty `encryptionKey` on a local datasource (a `file:` url or `:memory:`) or an embedded replica (a `file:` url beside `syncUrl`), detected or forced with `mode`, is refused now, at both doors together, with one message per mode that names the mode and what does encrypt. The key is judged by where the database lives, not by the mode: the same `file:` url or `:memory:` under a forced `mode: 'remote'` is refused too, in its own words, because the driver hands that url to the libSQL client it builds, which is handed no key, so the file was written in plaintext all the same:
+
+ - **at authoring**, as one `custom` issue on `encryptionKey` (`config.encryptionKey` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
+ - **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
+
+ For a local or replica datasource the message is the same text at all three places; for a `file:` url or `:memory:` under a forced `mode: 'remote'`, the schema and the constructor give the forced-remote text, while the driver mirror, which declares no `mode` and strips it, refuses the same config in the local text. A test holds the constructor's copy equal to the schema's issue byte for byte in every mode, and the mirror's verdict and words equal to the schema's wherever no `mode` is forced. It comes last among the local-engine refusals: a url the engine cannot open, a lone `sync` and a forced `mode` the driver would ignore each keep their own message. An empty `encryptionKey` is unset and is still accepted. A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file.
+
+ ### Migration: FROM → TO
+
+ | You wrote | Write instead |
+ | --- | --- |
+ | `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
+ | `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way |
+ | `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way |
+ | either, where the data must be encrypted at rest and no encrypted volume is available | a remote Turso database (`url: 'libsql://my-db.turso.io'`), whose storage encryption is the server's to configure, not this key's |
+
+ The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
+
+ A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext.
+
+ Blast radius, measured on this tree: no example, template, CLI scaffold or published skill authors the key, and no host default or environment variable sets it (a turso `encryptionKey` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.
+
+
+
+### Patch Changes
+
+- 5e9b100: feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database
+
+ Clause-②: yes (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** accept-set narrowing at the database (ADR-0131 D1: "There is no nullable tenant column anywhere"). It changes what a data writer can store, not what a metadata author can write, so it ships as `patch` with this banner.
+
+ - **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns.
+ - **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed: .organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict.
+ - **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. On a database that has not taken the ceremony, a table this release creates (a newly rotated shard, or a cold table a newly configured lifecycle tier creates) is NOT NULL beside nullable siblings: an organization-less row the lifecycle mover would carry into it is refused there, and stays where it is until the ceremony attributes it. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
+ - **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly.
+
+ **The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [eee65af]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/driver-sql@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json
index 4b52b58fbdc..cb3419d366b 100644
--- a/packages/drivers/driver-turso/package.json
+++ b/packages/drivers/driver-turso/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/driver-turso",
- "version": "18.0.0",
+ "version": "18.1.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 f7729d66a68..65e7cf25423 100644
--- a/packages/formula/CHANGELOG.md
+++ b/packages/formula/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/formula
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/formula/package.json b/packages/formula/package.json
index bc05f5a50ef..188c19dea42 100644
--- a/packages/formula/package.json
+++ b/packages/formula/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/formula",
- "version": "18.0.0",
+ "version": "18.1.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 7b21b66a2f3..3e55d85b0b4 100644
--- a/packages/lint/CHANGELOG.md
+++ b/packages/lint/CHANGELOG.md
@@ -1,5 +1,272 @@
# @objectstack/lint
+## 18.1.0
+
+### Minor Changes
+
+- b42c621: fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
+
+ - **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
+ - **The react-page props check** read `` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
+
+ Both now refuse the key by name, as an `error`, whatever it names:
+
+ - a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
+ - `` on a react page reports `react-prop-retired`.
+
+ The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
+
+ ### FROM → TO
+
+ | before | after |
+ | --- | --- |
+ | `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
+ | `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
+ | ` ` on a react page: passed every authoring command | `react-prop-retired` |
+
+ **The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
+
+ ### The residue readers that go
+
+ - **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
+ - **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
+ - **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
+
+ ⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
+- e7befa7: feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
+
+ Clause-②: yes (widening)
+
+ - **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
+ - **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '' }` (`Field.lookup('', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
+ - **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
+ - **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
+ - **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
+ - **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.
+- b9a79ef: feat(spec): the `flows` translation face gains `approvals..label`, the step label of an `approval` node
+
+ Clause-②: yes (widening)
+
+ - **Before.** `TranslationDataSchema.flows.` carried `label`, `successMessage`, `errorMessage`, `screens.` (screen nodes) and `refusals.` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens..title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen.
+ - **Now.** `flows..approvals..label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens..title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals..label` instead of being told only that the node is not a screen.
+ - **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands.
+ - **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.
+- 0a3ce08: feat(lint): a CEL slot inside a page component's `properties` that overruns the platform's parse bounds is reported as a warning at `os validate`, `os build`, `os lint` and the page publish door
+
+ Clause-②: no
+
+ A source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. The component node's own `visibleWhen` was already refused for this (`visibility-predicate-over-budget`). The CEL slots declared inside the props bag were judged by no rule. These include a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. A page carrying such a slot saved clean through every door.
+
+ The new rule `page-predicate-over-budget` (`validatePagePredicateBounds`) reads the slots off each component's `ComponentPropsMap` row: every position where the row's parse produces a `{ dialect: 'cel', source }` envelope. It judges each one with `validateExpression`'s bounds verdict and names the key and the bound. It does not echo the source. It is a **warning** in 18.x: the page still saves and builds, and the publish door carries the finding on its `advisories` channel. A future major release refuses it. A within-bounds source draws nothing.
+
+ Fix: shrink the expression. Collapse an `||` chain into one `in [...]` test, or precompute the heavy part into a formula or roll-up field and test that field.
+- 30d21f6: feat(spec): a `page:tabs` item label gains a translation key, `pages..components..items..label`
+
+ Clause-②: yes (widening)
+
+ - **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
+ - **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
+ - **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
+ - **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
+ - **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
+- eddffc3: feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language
+
+ Clause-②: yes (widening)
+
+ - **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
+ - **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
+ - **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
+ - **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
+ - **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
+- c0ae356: fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component whose `properties` is present but not an object, nested components included (#24991)
+
+ Clause-②: no (narrowing: `os lint --strict` refuses a nested page component whose `properties` is `null` or an array)
+
+ A page component's `properties` is a record on every node. `PageComponentSchema` refuses anything else, but that parse only reaches the top-level components (`regions[].components[]` and `slots.*`). A nested component, in a container's `children`, a tab or accordion panel's `items[].children` or a card's `footer`, sits in an untyped slot that no parse judges. The component-props rule (`validateComponentProps`) then skipped a nested node whose bag was `null`, an array or a scalar. So a nested `record:related_list` with `properties: null`, or a nested `object-metric` with `properties: []`, passed `os lint --strict`, `os validate` and `tsc`, and the page published a component that had lost every prop it was authored with. An absent bag was already judged as `{}`; this was the present-but-not-an-object residual of the same skip.
+
+ The rule now refuses such a bag on every walked node, whatever its `type`, with one `component-props-invalid` error located at the node's `properties` path (for example `pages[0].regions[0].components[0].properties.children[0].properties`). The message names what was received (`null`, an array, a string). No per-prop verdicts are added beside it.
+
+ **BREAKING — what moves for consumers.**
+
+ - `os build`, `os validate` and `os lint` fail on a page whose nested component carries `properties: null`, `properties: []` or a scalar `properties`. Before, they passed.
+ - A page write in publish mode that carries such a component answered 200. It now answers `422 INVALID_METADATA` with a `component-props-invalid` issue at that path, because the page save door runs this same rule.
+
+ **Remedy.** Write the bag as an object of the props the component reads (`properties: { … }`), or omit the key when the component needs none. An omitted bag is judged as `{}`, so a component with required props still has to name them.
+
+ **Unchanged.**
+
+ - A top-level component's non-object bag is refused by the page parse, as before, and that parse runs before any authoring rule at both doors, so no second finding appears beside it.
+ - An absent bag keeps its earlier verdict: it is judged as `{}`.
+ - A valid bag is judged exactly as before. An unregistered component `type` with an object bag is still skipped.
+ - The finding takes the rule's existing id and severity (`component-props-invalid`, `error`). No public export or signature moves.
+ - Stored pages are not migrated and are not refused on read. A stored page with such a component keeps loading until it is next saved, and that save is judged.
+ - Measured before crossing: no page this repository ships authors a non-object `properties` bag.
+
+
+- 31f0f27: fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component props CEL slot over the platform's parse bounds (#25002)
+
+ Clause-②: no (narrowing: a page component props CEL slot over the platform parse bounds is refused at `os build` and the page door; it ships now under the amended 「甲」, #6015 `6303865025`)
+
+ A CEL source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. `page-predicate-over-budget` (`validatePagePredicateBounds`) finds such a source in the CEL slots declared inside a page component's `properties` bag: a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. The finding was a warning. It is now an error, and its authoring-rules entry moves from the advisory tier to the gating tier. This supersedes the warning-only behaviour described for the same rule earlier in this release line; its message no longer says "A future major release refuses it."
+
+ **BREAKING — what moves for consumers.**
+
+ - FROM: a warning at `os validate`, `os build` and `os lint`, and an entry on the page save door's `advisories` channel while the page saved. TO: an error. The three commands fail, and an active page write answers `422 INVALID_METADATA` with a `page-predicate-over-budget` issue located at the slot and naming the bound it overruns. The page is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
+ - The page door answers `INVALID_METADATA`, its existing author-time refusal envelope, not `VALIDATION_ERROR`.
+
+ **Fix (one line).** Shrink the predicate (collapse an `||` chain into one `in ['a', 'b', …]` test), or precompute the heavy part into a formula or roll-up field on the object and test that one field.
+
+ **Unchanged.**
+
+ - A within-bounds source draws nothing at any door.
+ - The component node's own `visibleWhen` keeps its existing verdict (`visibility-predicate-over-budget`), and this rule does not report it twice.
+ - Stored pages are not migrated and are not refused on read. A stored page with such a slot keeps loading and serving until it is next saved, and that save is judged. Saving a page as a draft is still allowed; publishing the draft is judged.
+ - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
+ - No public export or signature moves; `PagePredicateBoundsFinding.severity` is now `'error'`.
+ - Measured before crossing, through the built `@objectstack/lint`: 0 in-repo producers carry such a slot. Every page module under `examples/` and `packages/` (30 files, 65 page values) draws 0 findings, and so do the `app-crm`, `app-multi-package` and `app-todo` composed stacks. A literal scan of 9,919 tracked source files (13,840 string literals of 120+ characters) finds 0 sources over the bounds, and HotCRM at `c8a7a70` (611 files) finds 0. A planted 78-term control is found by both instruments.
+
+
+
+### Patch Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- fde7456: feat(spec)!: a list's `addRecord` block says what renders — `enabled` is required, `position` defaults to `top`, and `mode` / `formView` are retired
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes, and in 18.x under the standing rule for breaking narrowings: an ADR-0087 conversion carries every body that parsed under 18.0, so no stored row or built artifact is refused.
+
+ `AddRecordConfigSchema` — the `addRecord` block on a list view, an object's or a view container's `listViews`, a list page's `interfaceConfig` and a page `list-view` node — declared defaults the renderer never applied (`enabled` `true`, `position` `bottom`, `mode` `inline`) and two keys no renderer read. The list's Add button is drawn only for an explicit `enabled: true`, an absent `position` renders at the top, and every Add opens the object's create form. The block now says exactly that:
+
+ - **`enabled` is REQUIRED, with no default.** `addRecord: {}` is refused at parse (`os validate`, `os build`, the save doors) with a prescription naming the key, instead of drawing nothing.
+ - **`position` defaults to `top`**, where the renderer has always placed an absent position. Values stay `top` / `bottom` / `both`. No screen moves.
+ - **`mode` and `formView` are `retiredKey()` tombstones** (ADR-0049 enforce-or-remove): authoring either is a `tsc` error and a parse error carrying the prescription. The create form is chosen by convention instead: the create entry opens `formViews.create` when the object's view container declares it, else `form` (or `formViews.default`). How that form opens stays the renderer's.
+
+ ### FROM → TO
+
+ | before | after |
+ | --- | --- |
+ | `addRecord: {}` (parsed as `enabled: true`, drew nothing) | refused: write `addRecord: { enabled: true }` |
+ | `addRecord: { position: 'bottom' }` | refused: write `addRecord: { enabled: true, position: 'bottom' }` |
+ | `addRecord: { enabled: true }` (parsed `position: 'bottom'`) | parses as `position: 'top'`, where it always rendered |
+ | `addRecord: { enabled: true, mode: 'form' }` (any `mode`) | `addRecord: { enabled: true }` |
+ | `addRecord: { enabled: true, formView: 'create' }` | `addRecord: { enabled: true }`, with the form declared as `formViews.create` on the object's view container |
+
+ **The one-line fix:** delete `mode` and `formView`, and write `enabled: true` (or `enabled: false`) on every `addRecord` block. `os migrate meta --from 17` lists the edits for existing metadata sources; `--write` applies the ones it can prove, and you apply the rest by hand.
+
+ ### The ADR-0087 disposition
+
+ - D2 `add-record-mode-form-view-removed` strips `mode` and `formView` from every `addRecord` block it reaches (`stack.views[]` in all three persisted spellings, `objects[].listViews.*`, `pages[].interfaceConfig` and every page `list-view` node), with a notice per key. Lossless in pixels: no renderer read either key.
+ - D2 `add-record-enabled-defaulted` writes `enabled: true`, the default the 18.0 spec declared, onto a block that omits `enabled`, with a notice. Measured before choosing it: no producer in this repository (the showcase's two blocks, the lint fixture) or in objectui's Studio design surface writes `addRecord` without `enabled`, so the conversion carries the narrowing with a zero measured population. **One visible consequence, stated:** a stored block that omitted `enabled` drew no Add button, because the renderer read the raw body; converted, it draws the button its declaration always promised. To keep a list without one, write `enabled: false` or drop the block. An explicit `enabled` value is never touched.
+ - Both are retired from the load path: a live author is refused at parse and taught; stored rows, built artifacts inside their declared-floor window and `os migrate meta --from 17` replay them.
+ - `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/AddRecordConfig:mode` and `ui/AddRecordConfig:formView`; the D3 entries are `add-record-mode-form-view-retired` and `add-record-enabled-required`; the default moves (`enabled` `true` → none, `position` `bottom` → `top`) are declared in `DEFAULT_CHANGES_BY_MAJOR[18]`.
+
+ ### Also in this change
+
+ - The Studio view form and page form offer exactly `enabled` and `position` for `addRecord`.
+ - `@objectstack/lint`: `os explain list-view-field-unknown` no longer names `addRecord.formView` as a position another rule checks.
+ - The showcase's two `addRecord` blocks and the docs (`ui/create-vs-edit-form`, `ui/views`) drop `mode` / `formView` and state the create-form convention.
+- 8cfe1f9: fix(lint): `os validate`, `os build` and the object save door warn on a `validations[]` rule whose predicate reads `parent` (#24720)
+
+ Clause-②: no
+
+ A `validations[]` rule's predicates — a `script` or `cross_field` rule's `condition`, a `conditional` rule's `when`, and the same slots on the rules nested in its `then` / `otherwise` — are evaluated by the server against `record` and `previous` only. The master-detail header is bound as `parent` for a field's own `readonlyWhen` and `requiredWhen` alone, on an object with exactly one `master_detail`. A rule that read `parent`, such as `parent.status == 'paid'`, passed `os validate` silently, because `parent` is a root the platform declares. At runtime the predicate faulted (`Unknown variable: parent`) and every write the rule judged was refused as unevaluable, the seed's own rows at boot included.
+
+ The build's expression rule (`validateStackExpressions`) now reports such a predicate at `warning`, located where the rule's other findings are (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label). The message names the slot, the rule and the root. The object save door runs the same pass and lists the same finding among its advisories: the same rule id (`expression-invalid`), location and message.
+
+ **Nothing new is refused in 18.x.** A warning fails neither `os build` nor `os validate`, and a publish-mode object save that carries such a rule is still admitted. No ADR-0087 conversion can rewrite a predicate that reads `parent`, so under the post-GA rule for such narrowings only this warning ships in 18.x. The refusal, the same finding at `error`, is planned for 19.0. It will also cover a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true, because the verdict judges the rule as written.
+
+ **Remedy.** To lock or require a field on the header's state, write the predicate on that field's own `readonlyWhen` or `requiredWhen`, where `parent` is bound. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail.
+
+ **Unchanged.**
+
+ - The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`. If the runtime comes to bind `parent` there, this verdict is withdrawn in that same change.
+ - `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, as before, and so is a `record` column spelled like the root (`record.parent_code`).
+ - No example or package object in this repository authors `parent` in a `validations[]` rule.
+ - No public export or signature moves.
+- 88cc145: docs(lint): `os explain` covers `list-view-object-unknown` and `list-view-object-outside-closure`, and the `packageScope` docblock says what a whole-collection miss now means
+
+ Clause-②: no (no verdict, severity, accept set or served body moves: explanation text and comments only)
+
+ - **`os explain list-view-object-outside-closure`** explains the package closure a write into a package is judged against (the package's own objects, its declared `dependencies`' objects, platform objects and provenance-less tenant objects), why a list view bound outside it is refused, and the remedy: declare the dependency, or bind the view inside the closure.
+ - **`os explain list-view-object-unknown`** explains why a write that names no package can say an object exists nowhere (it is judged against the whole collection the deployment serves), why an empty collection is not judged, and why `os validate` / `os build` / `os lint` still skip the binding.
+ - **`os explain list-view-field-unknown`** no longer says an unresolvable binding always skips the list view: it does on the CLI, and the runtime save door refuses it under one of the two ids above.
+ - **`AuthoringRuleContext.packageScope`** (in the published `.d.ts`) no longer says an object missing from a whole snapshot "may come from another package": that is true only on the CLI. At the runtime door with no closure in force the snapshot is the whole served collection, and such a binding is refused `list-view-object-unknown`.
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/sdui-parser@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/lint/package.json b/packages/lint/package.json
index 67fe1aee9d1..0d6fa3a06c7 100644
--- a/packages/lint/package.json
+++ b/packages/lint/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/lint",
- "version": "18.0.0",
+ "version": "18.1.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 f6012ac826a..616dac56ac5 100644
--- a/packages/mcp/CHANGELOG.md
+++ b/packages/mcp/CHANGELOG.md
@@ -1,5 +1,42 @@
# @objectstack/plugin-mcp-server
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 1c4eb53d73c..91a09c8b2ba 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/mcp",
- "version": "18.0.0",
+ "version": "18.1.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 0ffa8a0ee45..a6e99d366f2 100644
--- a/packages/metadata-core/CHANGELOG.md
+++ b/packages/metadata-core/CHANGELOG.md
@@ -1,5 +1,96 @@
# @objectstack/metadata-core
+## 18.1.0
+
+### Minor Changes
+
+- 386f60f: fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value
+
+ Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead)
+
+ Metadata-protocol: narrowed
+
+
+
+ **BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`.
+
+ - **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads.
+ - **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier).
+ - **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before.
+ - **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`.
+
+ `@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite.
+
+ `@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated.
+
+### Patch Changes
+
+- b42c621: fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
+
+ - **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
+ - **The react-page props check** read `` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
+
+ Both now refuse the key by name, as an `error`, whatever it names:
+
+ - a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
+ - `` on a react page reports `react-prop-retired`.
+
+ The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
+
+ ### FROM → TO
+
+ | before | after |
+ | --- | --- |
+ | `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
+ | `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
+ | ` ` on a react page: passed every authoring command | `react-prop-retired` |
+
+ **The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
+
+ ### The residue readers that go
+
+ - **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
+ - **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
+ - **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
+
+ ⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json
index 96e855b4194..b9e87beb086 100644
--- a/packages/metadata-core/package.json
+++ b/packages/metadata-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-core",
- "version": "18.0.0",
+ "version": "18.1.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 2c42eb4b6bf..0790c83ba34 100644
--- a/packages/metadata-fs/CHANGELOG.md
+++ b/packages/metadata-fs/CHANGELOG.md
@@ -1,5 +1,13 @@
# @objectstack/metadata-fs
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [b42c621]
+- Updated dependencies [386f60f]
+ - @objectstack/metadata-core@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json
index 1cdbc65a552..cadfcbb3a9d 100644
--- a/packages/metadata-fs/package.json
+++ b/packages/metadata-fs/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-fs",
- "version": "18.0.0",
+ "version": "18.1.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 cb897c6a294..3c494fdae8f 100644
--- a/packages/metadata-protocol/CHANGELOG.md
+++ b/packages/metadata-protocol/CHANGELOG.md
@@ -1,5 +1,110 @@
# @objectstack/metadata-protocol
+## 18.1.0
+
+### Minor Changes
+
+- 7cacad5: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
+
+ Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
+
+
+
+ **BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
+
+ - **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
+ - **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
+ - **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
+ - **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
+ - **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
+ - **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
+
+ Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
+
+### Patch Changes
+
+- b42c621: fix(lint)!: `os lint` and the react-page props check report the retired list-view `filterableFields` by name, and the readers that still treated it as a field position go
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `@objectstack/spec` 18 retired the list view's `filterableFields` with a `retiredKey()` tombstone, so `os validate` and `os build` refuse it at the parse. Two authoring checks still read it as a live field position:
+
+ - **`os lint`** never parses: it judges the normalized stack, and the conversion that strips the key is retired from the load path. On a JSON, YAML or raw-object source the key reached the list-view field-reference rule as written. A valid field name passed `os lint`, and a stale one was reported as an unknown field (`list-view-field-unknown` / `list-view-field-dotted`), never as the retirement.
+ - **The react-page props check** read `` as a field-bearing prop. A react page's source is parsed against no list-view schema, so `os validate`, `os build` and `os lint` passed the prop whenever its names resolved.
+
+ Both now refuse the key by name, as an `error`, whatever it names:
+
+ - a list view (every rung: an object's `listViews`, a `defineView` container's `list` / `listViews`, the flattened list overlay and the view item record) reports `list-view-key-retired` at `….filterableFields`, whether or not its object binding resolves;
+ - `` on a react page reports `react-prop-retired`.
+
+ The message names the retirement and the hint is the tombstone's own "Delete the key" sentence, read from `ListViewSchema` rather than retyped. The new rule id is exported from the package root as `LIST_VIEW_KEY_RETIRED`.
+
+ ### FROM → TO
+
+ | before | after |
+ | --- | --- |
+ | `filterableFields: ['status']` on a list view in a non-`defineStack` source: `os lint` passed | `os lint` fails with `list-view-key-retired` |
+ | `filterableFields: ['ghost']` on such a list view: `list-view-field-unknown` | `list-view-key-retired` |
+ | ` ` on a react page: passed every authoring command | `react-prop-retired` |
+
+ **The one-line fix:** delete `filterableFields` (the key or the prop). For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }`; the Filter panel offers every field the user may read. `os migrate meta --from 17` lists the edits for existing metadata sources; a react page's source is yours to edit by hand.
+
+ ### The residue readers that go
+
+ - **`@objectstack/lint`**: the list-view field-reference rule no longer walks `filterableFields` as a filter position (its severity and dotted-path maps), the field-consumer rule no longer counts it as a behavioural read, and `os explain list-view-field-unknown` / `list-view-field-dotted` no longer name it as a checked position.
+ - **`@objectstack/metadata-protocol`**: `computeViewReferenceDiagnostics` no longer walks `filterableFields`, the same deletion its retired sibling `tabs` had. A stored body has the key stripped by the conversion replay before it is served, and the write door refuses it.
+ - **`@objectstack/metadata-core`**: the ADR-0106 field-security scrub no longer classifies `filterableFields` as a list-view name list. A body that still carries the key goes the unclassified way and fails closed: a list view whose `filterableFields` names a field the caller may not read is dropped whole, where the scrub used to remove the name.
+
+ ⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning and their checks.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [8cfe1f9]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [88cc145]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0a3ce08]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [c0ae356]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [31f0f27]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/lint@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+ - @objectstack/sdui-parser@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json
index c91a1dc61c9..42bdd9a15ea 100644
--- a/packages/metadata-protocol/package.json
+++ b/packages/metadata-protocol/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata-protocol",
- "version": "18.0.0",
+ "version": "18.1.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 6e1bb19d2b8..6801a763692 100644
--- a/packages/metadata/CHANGELOG.md
+++ b/packages/metadata/CHANGELOG.md
@@ -1,5 +1,44 @@
# @objectstack/metadata
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/types@18.1.0
+ - @objectstack/metadata-fs@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/metadata/package.json b/packages/metadata/package.json
index ad5290881fb..7521fcb72db 100644
--- a/packages/metadata/package.json
+++ b/packages/metadata/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/metadata",
- "version": "18.0.0",
+ "version": "18.1.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 6a6548aa77a..f04ab793bf8 100644
--- a/packages/objectql/CHANGELOG.md
+++ b/packages/objectql/CHANGELOG.md
@@ -1,5 +1,274 @@
# @objectstack/objectql
+## 18.1.0
+
+### Minor Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 7532ea3: feat(objectql): a caller value that a stored field's `expression` replaced is reported through `onFieldsDropped` / `droppedFields` under the new `DroppedFieldsEvent.reason` value `stored_expression`
+
+ Clause-②: yes (widening)
+
+ The engine computes a stored (non-`formula`) field's declared `expression` on every write, so a value the caller sends for such a field never stands. Until now nothing said so. `onFieldsDropped` stayed silent, so the PATCH and `/batch` doors answered with no `droppedFields`, and a flow's `update_record` reported success over a row that holds the computed value instead of the one sent.
+
+ - **New `reason` value.** `DroppedFieldsEventSchema.reason` (`@objectstack/spec/data`) gains `stored_expression`. It means the field is a stored column the engine computes from its `expression`, and the caller's value was replaced by the computed one. The field IS written, so `computed` (a `formula` with no column, whose key is dropped) would be wrong for it. The four existing values are unchanged.
+ - **When it is reported.** On insert (single, batch, and `insertMany`'s per-row `droppedFields`), on update by id, and on a predicate update, in every context, `isSystem` included. It is reported only when the caller sent the field and the value stored differs from the caller's value. A client that sends the computed value (the console's line-item grid) or no value is told nothing. A value another strip already took, such as a stored expression field that is also `readonly`, is reported once, under that strip's reason.
+ - **`strictReadonlyWrites` is unchanged.** The maintainer's ruling 「甲」 says only the loud half ships in 18.x. A strict write whose value for such a field differs from the computed one is NOT refused: it completes with the computed value, and the replacement is reported through `onFieldsDropped` under strict as well. A refusal is runtime behaviour that no conversion can carry, so the strict refusal of a `stored_expression` replacement is deferred to 19.0. A strict write that also carries a refusable drop (`readonly`, `readonly_when`, `primary_key`, `computed`) is refused as before, for those drops only, and fires no listener.
+ - **Flow step warning.** `@objectstack/service-automation` words the new reason. Its warning says the requested value(s) were not stored, rather than that the field was not written, and its closing sentence says the write succeeded with the computed value(s).
+
+ **For consumers that branch on `reason`.** A `Record` table fails its typecheck until it has a `stored_expression` row. That is the intended signal: add the row. A client running behind the server should treat a reason it does not recognise as "not applied", never as one of the known reasons.
+- 5050442: fix(objectql,plugin-security): an update's re-point of `organization_id` that the Layer 0 wall admits is stored as sent, not silently dropped
+
+ Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
+
+ **What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an update's `organization_id`. The Layer 0 write wall admitted C, on the payload and on the row about to be stored. Then the engine's static-`readonly` update strip took it, because the platform's injected tenant column is `readonly: true`, and the row stayed in A. The update answered 2xx, by id and by predicate alike, and the only signals were a `droppedFields` entry and a WARN. An object that declares its own writable `organization_id` stored the same re-point, so the app objects on the injected column were the inconsistent half. This is the update half of the insert fix shipped beside it.
+
+ **What changed.**
+
+ - **`@objectstack/objectql`.**
+ - `ObjectQL.update` reads `PostHookWriteImageCheck.judgesOrganization` (reached as `OperationContext.postHookWriteImageCheck`) once per update, for a non-system caller, and hands that one value to every static-`readonly` pass: the pre-hook hide pass, the by-id strip, the predicate strip, and the stored view the `readonlyWhen` strips and the master-detail settlement read.
+ - Where it is set, and the object's `organization_id` is the platform's injected column on an object the insert strip also judges, those passes leave the caller's re-point on the row. The wall then stores it as sent where it admits it and refuses it where it does not.
+ - The decision is one helper, shared with `ObjectQL.insert`, so the two write verbs spare exactly the same objects.
+ - The member's published doc now covers updates. No member is added.
+ - **`@objectstack/plugin-security`.**
+ - Step 3.7 sets `judgesOrganization` on the stored-image seam it composes for a single-payload update, only where it composes the wall's own parts in, as it already did for an insert.
+ - It does not set the member on a business-`check`-only seam, on a crossing platform operator's update (which gets no wall seam), or on an update whose payload is an array (step 3.7 composes no seam for one).
+ - The wall's admit/refuse set is unchanged: a re-point outside the caller's scope is still refused `403 PERMISSION_DENIED`, and so is an emptied one (`null` or `''`).
+
+ **What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip and the strict flag) refuse nothing they accepted before.
+
+ - **The re-point is stored as sent.** Under `group`, an update naming another organization of the caller's membership set moves the row there, by id and by predicate. Under `isolated`, an update naming the active organization is stored as before.
+ - **No longer reported.** Such a re-point is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
+ - **`strictReadonlyWrites` stores it.** An in-process `engine.update(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
+ - **`beforeUpdate` hooks see it.** On a walled update, `beforeUpdate` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
+ - **A member with no active organization.** Under `group`, such a member re-points within its membership set the same way, and outside it is refused.
+ - **An on-behalf-of update (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The re-point is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted re-point was taken and the row kept its organization.
+ - **Outcomes that read the stored organization now follow the admitted value.** An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate, and a hook that reads `organization_id` now see the organization the row is moved to, not the one it used to keep. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, as it already was on an object that declares its own `organization_id`; before, the re-point was dropped and the write reported success.
+ - **Only the platform's injected column on an app object is spared.** A `sys_` object, a platform-internal `managedBy` bucket (`engine-owned`, `append-only`, `better-auth`), and an object whose author declares its own `organization_id` with `readonly: true` keep the update strip: the value is still taken, reported, and refused under strict, exactly as before. The organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) declare a writable `organization_id` the strip never took, so their re-points keep meeting their own guards exactly as before.
+ - **Where no wall judges the column, nothing changes.** The re-point is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, a platform operator crossing the wall on its own behalf, and `preserveAudit`, whose update-only exemption never reaches `organization_id`.
+ - **The `update`-mode `validate()` preview is unchanged.** It runs no middleware, so it holds no seam: it still reports an admitted re-point as dropped while the write stores it.
+- 2b1382a: fix(objectql,plugin-security): an insert's explicit `organization_id` that the Layer 0 wall judges is stored as sent, not replaced by the active organization
+
+ Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
+
+ **What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an insert's `organization_id`. The Layer 0 write wall admitted C. Then the engine's static-`readonly` strip took it, because the platform's injected tenant column is `readonly: true`, and the driver stamped A. The insert answered 2xx with the row in A, single and array alike. The only signals were a `droppedFields` entry and a WARN. Under `isolated`, a caller that named its own active organization saw the same strip and report, for a value that ended up stored anyway.
+
+ **What changed.**
+
+ - **`@objectstack/objectql`.**
+ - `PostHookWriteImageCheck` (reached as `OperationContext.postHookWriteImageCheck`) gains an optional member, `judgesOrganization?: boolean`. An enforcement layer sets it when the seam's `evaluate` holds every inserted row's `organization_id` to the tenant wall.
+ - `ObjectQL.insert` reads that member to decide. When it is set and the object's `organization_id` is the platform's injected tenant column, a non-system caller's explicit `organization_id` stays out of the pre-hook withhold and out of the static-`readonly` strip. The injected column is told apart by the registry's provenance reader; an authored column keeps the strip. The value is then stored as sent where the wall admits it, and refused by the wall where it does not.
+ - When the member is absent or `false`, both passes run exactly as before. That covers a seam that carries only a business `check`, a stack without an enforcement layer, and `validate()`, which runs no middleware.
+ - **`@objectstack/plugin-security`.**
+ - Step 3.7 sets the member only where it composes the wall's own parts into an insert's stored-image seam.
+ - It does not set the member on a business-`check`-only seam, on a crossing platform operator's seam, or on any update.
+ - The wall's admit/refuse set is unchanged: an organization outside the caller's scope is still refused `403 PERMISSION_DENIED`.
+
+ **What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and nothing accepted before is refused now.
+
+ - **The optional member.** `PostHookWriteImageCheck.judgesOrganization` (`OperationContext.postHookWriteImageCheck`) is new published surface on `@objectstack/objectql`.
+ - **The value is stored as sent.**
+ - Under `group`, an insert naming an organization of the caller's membership set is stored there.
+ - Under `isolated`, an insert naming the active organization is stored as before.
+ - **No longer reported.** Such an organization is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
+ - **`strictReadonlyWrites` stores it.** An in-process `engine.insert(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
+ - **`beforeInsert` hooks see it.** On a walled insert, `beforeInsert` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
+ - **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall.
+ - **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored.
+ - **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value.
+ - **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`).
+ - **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf.
+- 688052e: fix(objectql, plugin-auth)!: deleting an organization that still owns records is refused with 409 and a per-table count, before anything is deleted; an empty one is deleted whole, its teams and organization-admin grants with it; the engine never clears a tenant column
+
+ **The 18.0.0 defect this fixes.** `POST /api/v1/auth/organization/delete` ran better-auth's delete, which removes the organization's members, then its invitations, then the organization row, as three separate writes. The engine's delete cascade then applied the tenant column's `set_null` default to every row the organization still owned. On a database whose tenant column is NOT NULL (one that took the v18 organization-ownership ceremony), the delete answered 500 on `sys_team.organization_id` after the members and invitations were already gone, so the organization survived with no owner and nobody could delete it. On a database 18.0.0 created, where the column is still nullable, the delete answered 200 and left the organization's teams and records with `organization_id = NULL`, owned by nobody and visible to nobody. Every organization created through the door owns a default team, so on a NOT NULL database not even an empty organization could be deleted. Deployments that run `single` are not reached the same way: their one organization is the Default Organization, which the last-administrator guard answers first, and they cannot create another.
+
+ **What happens now.**
+
+ - An organization that still owns records is refused with `409` and the standard `DELETE_RESTRICTED` code. The body names each table and its row count, `dependents: [{ object, count }]`, and never a row's values. The refusal runs before better-auth's handler, so nothing is written, the caller's active organization included. A caller the organization does not let delete it still gets better-auth's own `403`, with no count.
+ - Append-only ledgers (objects declared `managedBy: 'append-only'`, such as `sys_activity`, `sys_audit_log` and `sys_email`) are history, not records. They never refuse the delete, and their rows are kept: every reference they hold to the organization (`organization_id` on `sys_activity` and `sys_email`, the attribution `tenant_id` on `sys_audit_log`) goes on naming the deleted organization, never NULL and never deleted. The delete writes activity rows of its own, so counting them would refuse every delete.
+ - Engine-owned rows (objects declared `managedBy: 'engine-owned'`) are treated the same way. The platform writes them as a consequence of records or activity, and an administrator cannot delete them over the data API: approval requests, approvers and tokens; automation runs; notifications, their receipts and deliveries; outgoing HTTP deliveries; mailboxes and mirrored mail; record shares and share links; import jobs; audience-binding suggestions; and organization-level settings values and their audit. They never refuse the delete, and they are kept, still naming the deleted organization.
+ - So engine-owned and append-only rows outlive the organization, and go on naming it. Under tenant scoping no organization reads them.
+ - An attribution column `organization_id` on an object whose tenancy is disabled is kept the same way: not counted, not cleared. A pointer to the organization, such as a session's or an API key's `active_organization_id`, is cleared as before.
+ - The Default Organization (slug `default`, or the deployment's only organization, never under an organization wall) is asked the last-administrator guard's question first, and answers `403 PERMISSION_DENIED` while deleting it would leave no platform administrator. Once the guard admits, it is counted like any other organization, before better-auth deletes anything, except that only its members and invitations are left out: its teams and grants are not removed, so they count, and the delete is refused `409 DELETE_RESTRICTED` while it holds them.
+ - An organization that owns nothing but its own auth structure is deleted whole: its members and invitations (better-auth), and its teams, their members and the organization-admin grants its owners and admins hold (plugin-auth, from `beforeDeleteOrganization`). Any other grant scoped to the organization counts as a record.
+ - The request runs as one engine transaction and rolls back on any error answer, so a delete either removes all of that or none of it. On a datasource whose driver has no transactions, the refusals are the guarantee, because all of them run before the first write: the record count above, and, for the Default Organization, the last-administrator guard, whose `403` is now asked before better-auth deletes any member (it used to arrive after the members and invitations were gone).
+ - Under all of this, the engine itself refuses to delete a `sys_organization` row while any row still names it in its tenant column, whatever path reaches the delete: `409 DELETE_RESTRICTED` with the same `dependents` list, which better-auth's door now carries to the wire too. It counts each table rather than reading its rows. An organization is deleted by id only: a delete matching organizations by a filter (`multi: true`) is refused with `409 DELETE_RESTRICTED` naming the by-id route, because only the by-id delete runs this referential pass. A tenant column is never set to NULL.
+ - `ObjectQL.organizationOwnedRowCounts(organizationId, { exclude })` answers, per object, how many rows name an organization in their tenant column. It is the question the refusal asks.
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (the organization delete's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** deleting an organization that still owned records answered `500` after deleting its members and invitations (tenant column NOT NULL), or `200` with its teams and records orphaned to `organization_id = NULL` (tenant column nullable, an 18.0.0-born database). A direct engine delete of such a `sys_organization` row did the same.
+ - **TO:** both answer `409 DELETE_RESTRICTED`, with `dependents: [{ object, count }]` naming every table that still holds rows. Nothing is deleted or changed.
+ - **Fix:** delete the organization's records, or move them to another organization, then delete the organization. The refusal names the tables to look at. Business units, positions and sharing rules count as records. Activity, audit and mail-log rows do not, nor do engine-owned rows (approval state, runs, deliveries, shares, settings values).
+ - **Seed datasets:** on a composition with the organizations runtime (`@objectstack/organizations`) and an app that ships seed datasets, a new organization receives that seed replay at creation, so it owns records from its first moment and its delete is refused until they are removed. The refusal lists them. Delete them (or move them) like any other records, then delete the organization. Measured on the showcase: a fresh organization owns 132 seeded rows in 19 tables.
+ - **Already orphaned on 18.0.0?** Rows an earlier organization delete set to `organization_id = NULL` are not touched by this release. Organization-less rows are what the v18 organization-ownership ceremony (`os migrate organization-ownership`) attributes or reports.
+- 157ebd0: feat(cli, objectql): `os db orphans` — report the detail rows whose required master-detail master does not exist, and delete them only behind `--delete`, after a pre-delete export
+
+ Clause-②: yes (widening)
+
+ **What was wrong.** A system-context write is exempt from the write-time reference check, so an elevated writer could store a master-detail id that names no master. Such a row's access derives from that master, so every user door answered 404 for it, the administrator's `GET` and `DELETE` included, and a list query left it out. The integrity audit reported the row and, by design, never rewrites it. No door existed to remove it, so it stayed until someone edited the database by hand.
+
+ **`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it.
+
+ - **The default run is a report** that writes no row or table and creates no file. It boots read-only, as `os migrate plan` does. It lists each detail row whose required `master_detail` master does not exist, with its object, record id, field and missing master id. It also lists the deeper detail rows that the delete's cascade removes with it. `--json` prints one document, `{ mode: 'report', plan }`.
+ - **What is never deleted, and is said.** A reference whose target object could not be checked (not registered, no driver, or a failed read) is counted as `undetermined`. Unknown is not missing. A dangling reference on the same objects through a field that is not a required master-detail is listed in `otherDangling`. An orphan whose cascade could not be read is listed as `withheld`, with the reason. A scan bounded by `--max-rows` (default 100000) names every object it could not read in full.
+ - **`--delete` requires `--export `.** The export holds every record the run deletes, as the driver stores it. It is written owner-only, is created rather than overwritten, and is read back before the first delete. If it cannot be written or does not hold every record, the run refuses with nothing deleted. Just before deleting, the run reads the report again and skips an orphan whose master now exists, or whose cascade now reaches a record the export does not hold. Each delete is the engine's own system-context delete, so the master-detail's cascade removes the deeper details. A `restrict` dependent refuses the delete, and a cascade on one datasource is one unit of work, so nothing of that orphan is removed. A `set_null` reference to a deleted row is cleared. A roll-up on the missing master cannot be recomputed, and the run notes this rather than failing. Each delete is judged by reading its records back.
+ - **Refusals**, each exit 1 with nothing deleted, and the code under `--json` as `error`: `export_required`, `export_exists`, `export_failed`, `export_incomplete`, `confirmation_required` (`--json` without `--yes`), `boot_failed`, `no_engine` and `scan_failed`.
+
+ **`@objectstack/objectql`: `ObjectQL.inspectCascadeDelete(object, id)` (new method).** A read-only method that returns every record a by-id `delete()` of that record removes, root first. It is the cascade set the delete collects for itself, read through the same walk. It writes nothing, runs no hook, and files no reference-check elevation record, because no delete happens. It reads as the system with no organization. `restrict` and `set_null` relations are not listed, because neither removes a record. A dependents probe that fails propagates.
+
+ Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged.
+- 03c12a5: fix(objectql)!: under `strictReadonlyWrites`, a write whose value a stored field's `expression` replaces (`stored_expression`) is refused, and nothing is written
+
+ Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`)
+
+
+
+ **BREAKING** (the strict mode's refusal set grows by one reason), shipped as `minor` under the amended ruling 「甲」: a narrowing that no conversion carries ships now, in 18.x. It applies only to a caller that opts into the in-process `strictReadonlyWrites` option. No REST or wire caller can set that option, and every write without it behaves exactly as before.
+
+ `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed, one reported drop was left out: a caller's value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict, such a write completed and was only reported. It is now refused like every other strip's drop.
+
+ - **FROM:** a write with `strictReadonlyWrites: true` that sent a value for a stored expression field, different from the value the engine computes, completed. The row held the computed value, and `onFieldsDropped` received a `stored_expression` event.
+ - **TO:** the same write throws `ReadonlyFieldRejectedError` (`code: 'ERR_READONLY_FIELD_REJECTED'`), whose `drops` carries the `stored_expression` event. Nothing is written, and `onFieldsDropped` does not fire. This holds on insert (a single row, a batch, and `insertMany`, which refuses the whole call as it does for the other strict drops), on update by id, and on a predicate update.
+ - **One refusal names everything.** When the same payload also carries another strip's drop (`readonly`, `readonly_when`, `primary_key`, `computed`), the one refusal names all of them together, `stored_expression` last. Before, such a write was refused for the other drops alone. The message's per-reason form gains a `stored_expression` remedy, spoken only when that reason is present, so every other refusal's message is unchanged.
+ - **Ordering is kept.** The strips' refusal still comes first: if the stored-expression pass itself throws, for example a predicate update whose patch computes different values on different matched rows (`VALIDATION_FAILED`), a strict write that also carries a strip's drop is still refused with `ERR_READONLY_FIELD_REJECTED` naming those drops, as before.
+ - **`@objectstack/spec` (patch):** the TSDoc on `WriteObservabilityOptions.strictReadonlyWrites` and `DroppedFieldsEvent.reason`, which ships in the published declarations, now says the `stored_expression` arm is refused under strict. No schema, `.describe()` text or export changes.
+ - **Unchanged:** a strict write that sends the computed value, or no value for the field, is not refused, because nothing it sent was replaced. A stored expression field also declared `readonly` is still refused once, as `readonly`. Without `strictReadonlyWrites`, the write completes with the computed value and the replacement is reported, as before. The error code is unchanged.
+ - **The fix for a strict caller:** leave the stored expression field out of the payload, or send the value its expression computes. To let the replacement happen and merely observe it, drop `strictReadonlyWrites` and pass `onFieldsDropped`.
+
+ This supersedes the `strictReadonlyWrites` bullet of the `stored_expression` reason's entry, which deferred this refusal to a later major.
+
+ Measured before the change: no non-test caller in this repository passes `strictReadonlyWrites` (the examples, dogfood suites and runtime doors pass none), so no in-repo producer moves.
+- f8c5fb3: feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered)
+
+ Clause-②: no
+
+ **Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe.
+
+ **Now.**
+
+ - `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope.
+ - `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`.
+
+ **What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright.
+
+ **Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection.
+- d96f618: feat(objectql): the engine composes a `ReadableIds` leaf that sits inside a composed read scope — the nested master chain (ADR-0139 D6 as amended)
+
+ Clause-②: no
+
+ **Before.** The executor boundary composed every `ReadableIds` leaf in a read's own `where` with the parent's read scope, and handed the driver that scope as answered. A scope that itself carried a leaf — a parent whose read scope is derived from ITS master, `{ master_fk: { $in: ReadableIds.of(G) } }`, the shape a two-level `controlled_by_parent` chain takes once its scope is spelled as a leaf — reached the driver uncomposed inside the parent's subquery, and the driver refused it, `INTERNAL_ERROR` / 500 (measured on driver-sql).
+
+ **Now.** Every leaf a composed scope carries is composed the same way, before the leaf that encloses it — the same five steps, the same caller context at every level (a delegated context included), recursively. The driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested; a two-level chain serves exactly the rows today's id-list pre-scan serves, for a direct and for a delegated caller (measured on the real stack: `find` and `count` agree, one statement per read). Each parent is asked once per read, at whichever level it first appears. The closed doors hold at every level: `canReadObject` false, the deny sentinel, a throw on either half, or a parent the registry does not hold answer `$in: []` inside the composed scope. Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: D5's two classes at a nested level (a middleware-gated or federated master), a nested leaf outside a `$in` comparand or a composed leaf the engine did not compose inside a scope, a chain that returns to a parent already on its branch, and a chain deeper than 8 scopes (the same cut as the `controlled_by_parent` derivation's).
+
+ **What a reader sees.** No shipped security service answers a read scope carrying a leaf yet — `controlled_by_parent`'s scope is still the pre-resolved id list (ADR-0055 §2 mechanism (b)) until the plugin-security switch lands on its own card — so every shipped read is served as before. The six consumers' leaves compose exactly as they did: a scope with no leaf is composed by reference, one level asked.
+
+ **Unchanged.** `controlled_by_parent`'s own read scope and the id-list pre-scan, ADR-0055, every consumer's probe routing, and the drivers' compilation of the composed leaf (driver-sql and driver-memory already compile a scope that holds a composed leaf, through their own filter path).
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- 72518d2: fix(objectql): an advisory validation rule that cannot be evaluated logs one line saying the write proceeded, not two saying it was rejected (#24782)
+
+ Clause-②: no (log text only: no response, status, code, outcome or export moves)
+
+ A `severity: 'warning'` or `'info'` validation rule whose predicate cannot be evaluated never blocks the write. Only `error` rules fail closed (#4649). The write answers 2xx and the row persists. The server log said the opposite, twice. The predicate evaluator logged "— write rejected: a rule that cannot be evaluated fails closed, it is never skipped" before the rule's severity was consulted. Then the advisory per-write line repeated the unevaluable violation's own message, which also ends "— write rejected.".
+
+ Such a rule now logs ONE line: `Validation rule 'NAME' (warning): the rule could not be evaluated (FAULT) — write proceeded: the rule is advisory, so it was not applied to this write`. This covers a `script` / `cross_field` condition, a `conditional`'s `when`, and the relationship-traversal refusal. On the seed / boot path the same text folds into that load's advisory summary line, which keeps its shape.
+
+ Unchanged: an unevaluable `error` rule is refused with the same `400 VALIDATION_FAILED` body, and its log line still says the write was rejected and the rule fails closed. Which rules block is unchanged. An unevaluable rule still stays out of a write answer's `warnings`. `requiredWhen`, `readonlyWhen` and the option `visibleWhen` gate still refuse on every severity.
+- 6b656bf: fix(objectql): a unique refusal's user-facing sentence names the object and the field by label
+
+ Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves)
+
+ `DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`.
+
+ - **The sentence now uses the declared `label`.** The engine looks up the object's and the field's labels in its registry on every write door that raises the refusal: insert (single and batch), and update by id and by predicate. Example: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` Where no label is declared, the sentence uses the API name, exactly as before. The opening words and `No record was written.` are unchanged.
+ - **The API names stay available for diagnostics.** They are still on `object` and `field`, and `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. …`.
+ - **Unchanged:** the code (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. `DuplicateRecordError`'s constructor takes an optional fourth argument with the labels. Callers that pass three arguments get the API-name sentence.
+ - **For HTTP clients:** on the REST data routes the curated `error` text is unchanged. The wire `developerMessage` (which carries the engine's sentence) now names the labels, and the body's `object` and `field` keys still carry the API names.
+- db9c02a: fix(objectql): a federated object whose datasource the connect policy denied is reported at info, not in the ERROR that claims its reads hit the wrong table
+
+ Clause-②: no (log text and level only: no response, status, outcome, boot verdict or export moves)
+ Metadata-protocol: none (a boot log line's level and text; nothing a metadata author writes changes)
+
+ - **What it was.** At `kernel:ready`, `ObjectQLPlugin` re-binds every federated (`external`) object to its remote table and reports, at ERROR, each one it could not bind. The ERROR says the object stays served while every read against it "resolves to a table named after the OBJECT … no such table, or the wrong table". It said that about an object whose datasource the host's connect policy had denied on purpose. `os migrate apply` on the stock showcase, whose managed-only policy denies every `schemaMode: 'external'` datasource, printed it after a successful apply.
+ - **What changed.** When the datasource layer records the datasource as refused by policy (availability `blocked`), its unbound objects are reported in one `info` line instead. The line names the objects and their datasource, and says every read against them is refused with `DatasourceUnavailableError` until the policy allows that datasource. That is what the engine already does for such a read. The same line appears on every composition, serving or not, because it follows the recorded verdict alone.
+ - **Unchanged.** The ERROR, word for word, for every other object it covered: a datasource that was never declared, a connect that FAILED (availability `failed`), a driver without external-object registration, and a registration that threw. On a boot with both, the info line covers the denied objects and the ERROR covers the rest, and the ERROR's counts exclude the denied ones. No binding, connect policy or read result changes.
+ - **What to do.** Nothing. A log filter or agent that keyed on the ERROR for a policy-denied datasource now finds the info line, which names the same objects and datasource.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-protocol@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/objectql/package.json b/packages/objectql/package.json
index 10cc1602985..d002fe83761 100644
--- a/packages/objectql/package.json
+++ b/packages/objectql/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/objectql",
- "version": "18.0.0",
+ "version": "18.1.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 42bc5c65a00..b3e7a556e0d 100644
--- a/packages/observability/CHANGELOG.md
+++ b/packages/observability/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/observability
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/observability/package.json b/packages/observability/package.json
index 2c4685c1f58..40bfe1cce56 100644
--- a/packages/observability/package.json
+++ b/packages/observability/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/observability",
- "version": "18.0.0",
+ "version": "18.1.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 368c606948f..e10ec830b7c 100644
--- a/packages/platform-objects/CHANGELOG.md
+++ b/packages/platform-objects/CHANGELOG.md
@@ -1,5 +1,195 @@
# @objectstack/platform-objects
+## 18.1.0
+
+### Minor Changes
+
+- a8b44d4: fix(platform-objects): a team record no longer lists Sessions with "Sign out other devices" (#24765)
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** for a reader of a team's derived related lists only: Setup → Teams → a team → Related no longer lists *Sessions*. Shipped as `minor` under the launch-window convention for accept-set narrowings.
+
+ - **Before.** `sys_session.active_team_id` is a lookup to `sys_team` that did not set `relatedList: false`, so every team record derived a Sessions related list. That list carried the session list's only toolbar action, "Sign out other devices", whose confirmation reads "Sign out of every other device where you're currently logged in?". The action signs out the administrator's own other sessions, not the team's.
+ - **Now.** `active_team_id` sets `relatedList: false`, so a team record derives no Sessions list. A team's Members and Invitations lists are unchanged.
+ - **Unchanged.** The `active_team_id` field, its lookup to `sys_team`, its label and its column: better-auth still writes `activeTeamId` there. A user record's Sessions tab (keyed on `user_id`), where "Sign out other devices" belongs, and the action itself.
+
+ **What to do.** Nothing. No team-scoped session action existed to lose.
+- 78ab647: fix(platform-objects): `SETUP_NAV_CONTRIBUTIONS` no longer carries the Setup → Branding entry; `@objectstack/service-settings` contributes it with the `branding` namespace
+
+ Clause-②: no (the entry moves to the package that serves its page: a composition that serves Setup → Branding still lists it, and one that does not no longer lists a page it cannot open)
+
+ The `nav_settings_branding` entry (Setup → Configuration → Branding) was a static item of `SETUP_NAV_CONTRIBUTIONS`, listed whatever the settings service offered. From this release `@objectstack/service-settings` does not offer the `branding` namespace under the `group` and `isolated` tenancy postures, so a static entry would open a page the server refuses with `404 UNKNOWN_NAMESPACE`.
+
+ `SettingsServicePlugin` (`@objectstack/service-settings`, same release) now contributes the entry from the same `kernel:ready` step that offers the namespace, and only then. On a single-organization deployment Setup still lists Branding, now at the end of the Configuration group's platform entries. Its translated labels are unchanged and still keyed `apps.setup.navigation.nav_settings_branding`.
+- 4b8f597: fix(platform-objects)!: deleting a business unit that still has child units is refused, instead of silently turning each child into a root of the tenant
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (the delete of a business unit with children is refused), shipped as `minor` under the launch-window convention for accept-set narrowings.
+
+ - **FROM:** `parent_business_unit_id` declared no `deleteBehavior`, so it took the lookup default `set_null`. Deleting a business unit that had child units answered `200`, and each child's `parent_business_unit_id` became `null`: a root of the tenant, without a word. A `unit_and_subordinates` sharing rule anchored above the deleted unit stopped reaching the former children.
+ - **TO:** the same delete answers `409 DELETE_RESTRICTED`, naming the blocking object (`dependentObject: 'sys_business_unit'`) and the number of child units (`dependentCount`), with the sentence "This Business Unit is still referenced by N Business Unit record(s) through “Parent Business Unit”. Delete or reassign them first." The unit and its children are left exactly as they were. The field's help text now says a unit with child units cannot be deleted until they are re-parented or deleted.
+ - **Unchanged:** a unit with no child units deletes as before. A bulk delete (`deleteMany`, `batch`) that lists the children before their parent (leaf to root) completes, atomic or not. Deleting an organization leaves its units and their tree in place, as before.
+ - **Fix:** before deleting a business unit, re-parent its child units (edit each child's Parent Business Unit) or delete them first. A bulk delete of a whole subtree lists the units leaf to root; listed root first, the root is refused.
+- ba244f1: fix(platform-objects)!: `sys_oauth_application.type` declares only `web` and `native`, the two application types its writer produces
+
+ Clause-②: no (narrowing) — the stored column's declared options, which `GET /api/v1/meta/object/sys_oauth_application` serves and the record validator judges a write against, lose `useragentbased` and `public`, which no writer produces.
+
+
+
+ **BREAKING** (a platform object's declared option set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ The column's only writer is the OAuth provider (`@better-auth/oauth-provider`), whose `application_type` plugin-auth maps onto `type`. The provider writes `web` or `native` on every creation and update path, or no type for a client-metadata-document client that names none. The generic data API cannot write the table. The column declared four options, and the string shorthand that declared them strips hyphens, so it served `web`, `native`, `useragentbased` and `public`. No writer produces the last two, and the `user-agent-based` an earlier provider line stored was never one of them.
+
+ - **FROM:** the `type` field of `sys_oauth_application` declared and served the options `web`, `native`, `useragentbased` and `public`. A write that sent `useragentbased` or `public` was accepted and stored.
+ - **TO:** it declares and serves `web` and `native`. A write that sends any other value, including `useragentbased` and `public`, is refused with `VALIDATION_FAILED` (`400` at an HTTP boundary) and an `invalid_option` finding on `type`. No door sends one: the provider sends only `web`, `native` or no type, and the generic data API answers `405` for every write to this table.
+ - **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. The register-form entry in this release refers to this entry for the stored column's options.
+ - **The fix:** nothing to change for a client the provider registered. A row an earlier provider line stored as `user-agent-based` keeps that value. To edit it through the provider's update path, set `application_type` (`web` or `native`) in the same update.
+
+### Patch Changes
+
+- 7035682: fix(platform-objects): the password an administrator or user types into an action dialog is masked, no longer shown in clear text
+
+ Clause-②: no (an input's presentation: the param still accepts the same string, and no accept set or served shape moves)
+
+ Twelve credential params on the identity objects were declared `type: 'text'`, so the console's action dialog drew each as a plain text input and the typed password was readable on screen and in screenshots. Each now declares `type: 'password'`, which the dialog draws as a masked input:
+
+ - `sys_user`: `create_user`'s `password` and `set_user_password`'s `newPassword` (Setup › Users), `change_my_password`'s `currentPassword` and `newPassword`, and the `password` of `delete_my_account`, `enable_two_factor`, `disable_two_factor` and `generate_backup_codes`.
+ - `sys_two_factor`: the `password` of `enable_two_factor`, `disable_two_factor` and `regenerate_backup_codes`.
+ - `sys_sso_provider`: `register_sso_provider`'s `clientSecret`.
+
+ Labels, names, `required` and the posted body are unchanged: the value is still posted as the string typed, and no endpoint changes what it accepts.
+- d606fc1: fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state
+
+ Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)
+
+ Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:
+
+ - `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
+ - `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
+ - `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.
+
+ The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.
+
+ Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
+- 73a2b52: fix(platform-objects): the OAuth application register form offers only Web and Native, and its Redirect URLs help states the rule for each (#24773)
+
+ Clause-②: no (a form's offering: nothing on the server reads the action's param options, so this change moves no accept set or served shape; the register route's refusal of the other types is its own breaking entry in this release)
+
+ Setup › OAuth Applications › Register offered four application types: Web, Native, User-agent based and Public. The OAuth provider judges only `web` and `native`, and the register route refuses any other type with `400 INVALID_REQUEST`. The form now offers Web and Native, and still defaults to Web.
+
+ - **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port.
+ - **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`.
+ - **Unchanged.** A row written under an earlier provider line keeps its stored type, and the `public` column is unchanged; the stored column's options narrow to Web and Native in this release's `sys_oauth_application.type` entry.
+
+ The zh-CN, ja-JP and es-ES translations carry the new help texts.
+- 5872450: fix(plugin-auth)!: `POST /api/v1/auth/sys-oauth-application/register` refuses an application type the OAuth provider does not judge with `400 INVALID_REQUEST`, where it registered it as a `web` client
+
+ Clause-②: no (narrowing) — the register door accepted any `type` value and registered every one other than `web` / `native` as a `web` client with a secret; it now refuses each of those values with 400, so it accepts exactly `web`, `native` and no type.
+
+
+
+ **BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ The OAuth provider (`@better-auth/oauth-provider`) judges two application types, `web` and `native`, and this door forwards the type the Setup → OAuth Applications form chose as the provider's `application_type`. Any other value has no provider counterpart. The door used to register such a request as a confidential `web` client, with a client secret and the web redirect-URI rule, which is a different kind of client from the one the request asked for. It now refuses the request instead.
+
+ - **FROM:** a request whose `type` is present and is neither `web` nor `native` (`user-agent-based`, `public`, any other string including the empty string, or a value that is not a string) answered `200` and stored a `web` client, or was refused by the web rule (`OAUTH_REGISTER_FAILED`) for a loopback redirect URI.
+ - **TO:** the same request answers `400` with `error.code: 'INVALID_REQUEST'`. The message names the value (its first 64 characters) and the two accepted types, and says to choose `native` for an `http://localhost`, `http://127.0.0.1` or `http://[::1]` redirect URI. The provider is not called, nothing is stored, and no warning is logged.
+ - **Accepted:** `web` and `native` are forwarded as the provider's `application_type` (this release's Application Type entry), and the provider's own redirect-URI rule decides.
+ - **Unchanged:** A request with no `type`, or with `type: null`, registers a `web` client, the provider's default. The other refusals keep their codes (their statuses are this release's refusal-status entry). Clients already registered are not touched.
+ - **`@objectstack/platform-objects`:** only the code comment on the `create_oauth_application` action changes, to state this refusal; it ships in the package's JavaScript. The form offers only Web and Native (this release's register-form entry).
+ - **The fix for an integrator:** send `type: 'web'` for a client whose redirect URIs are https on a host that is not a loopback address, or `type: 'native'` for a client that also redirects to `http://localhost`, `http://127.0.0.1` or `http://[::1]`. Omitting `type` registers `web`.
+- 9ea5afe: fix(platform-objects): Rotate Client Secret's result dialog states the immediate cut-off its confirmation states
+
+ Clause-②: no (text only: no accept set, served shape or behaviour moves)
+
+ - **Before.** On `sys_oauth_application`, the Rotate Client Secret confirmation said the previous secret "will stop working immediately". The result dialog then told the admin to "update every integration before the previous secret's grace period ends". Only the first is true.
+ - **What rotation does.** `POST /api/v1/auth/oauth2/client/rotate-secret` is better-auth's own route. It overwrites the client's one stored secret, and client authentication checks a presented secret against that value only. No previous secret is kept, so there is no grace period: the old secret is refused from the moment the rotation completes.
+ - **Now.** The result dialog reads "Save the new secret now — it is shown only once. The previous secret has already stopped working: update every integration with the new secret." The zh-CN, ja-JP and es-ES translations of that sentence are updated to match. The confirmation, the dialog's title, acknowledgement and secret field are unchanged.
+- 63085c3: feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing.
+
+ ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match.
+
+ **What moves for consumers, FROM → TO.**
+
+ - **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it.
+ - **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles.
+ - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged.
+ - **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.
+
+ **Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`.
+- 5d2c5a1: fix(platform-objects): the Create User result dialog no longer tells an admin who typed the password to copy a temporary one
+
+ Clause-②: no (text only: the create-user result dialog's sentence and button; no param, response, status, outcome or export moves)
+
+ `sys_user.create_user`'s result dialog said "Copy the temporary password now — it is shown only once and never stored." with an "I have saved this password" button, in both outcomes. The endpoint returns `temporaryPassword` only when it generated one, so after a typed password the dialog showed the email alone and asked the admin to save a password it never showed. The dialog now reads "If a temporary password was generated, copy it now — it is shown only once and never stored." with a "Done" button — the same sentence and button as `set_user_password`, true for a typed and for a generated password. The zh-CN, ja-JP and es-ES bundles carry the same change, reusing `set_user_password`'s existing translations.
+- 066dabc: fix(platform-objects): the user page's Security tab shows only on the viewer's own record
+
+ Clause-②: no (platform metadata only: the Security tab's self-service sections are shown on the viewer's own record only; no action, param, response, spec key or export moves)
+
+ The `sys_user` record page's Security tab holds only self-service controls: change password and email, two-factor authentication, email verification and account deletion. Every one of those actions is already gated to the viewer's own record. On another user's record the actions were withheld, but the tab still showed its four "your account" headings and captions with nothing under them, addressed to an administrator about an account that is not theirs.
+
+ The tab item now carries the own-record gate `has(record.id) && record.id == ctx.user.id` as an item-level `visibleWhen` in the CEL envelope (the same guard the page's unverified-email alert carries). On your own record the tab is unchanged. On anyone else's record the tab header and its panel are both omitted. The actions, their own gates and the other tabs are unchanged.
+- 386f60f: fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value
+
+ Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead)
+
+ Metadata-protocol: narrowed
+
+
+
+ **BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`.
+
+ - **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads.
+ - **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier).
+ - **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before.
+ - **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`.
+
+ `@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite.
+
+ `@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated.
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json
index aaf2a3d2b76..71b4b5b413a 100644
--- a/packages/platform-objects/package.json
+++ b/packages/platform-objects/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/platform-objects",
- "version": "18.0.0",
+ "version": "18.1.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 553feaff00d..ead2fd67d10 100644
--- a/packages/plugins/embedder-openai/CHANGELOG.md
+++ b/packages/plugins/embedder-openai/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/embedder-openai
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json
index e9734915dfc..ed8dd3b36ad 100644
--- a/packages/plugins/embedder-openai/package.json
+++ b/packages/plugins/embedder-openai/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/embedder-openai",
- "version": "18.0.0",
+ "version": "18.1.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 fccac362256..c6d03a42a85 100644
--- a/packages/plugins/knowledge-memory/CHANGELOG.md
+++ b/packages/plugins/knowledge-memory/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/knowledge-memory
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-knowledge@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json
index 37f32c6c263..edad31cfa51 100644
--- a/packages/plugins/knowledge-memory/package.json
+++ b/packages/plugins/knowledge-memory/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/knowledge-memory",
- "version": "18.0.0",
+ "version": "18.1.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 a2e48b90fe1..9cb609eddb6 100644
--- a/packages/plugins/knowledge-ragflow/CHANGELOG.md
+++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/knowledge-ragflow
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-knowledge@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json
index d151c7bebfe..42eb81688dd 100644
--- a/packages/plugins/knowledge-ragflow/package.json
+++ b/packages/plugins/knowledge-ragflow/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/knowledge-ragflow",
- "version": "18.0.0",
+ "version": "18.1.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 ab9b3f63512..adc75b375d7 100644
--- a/packages/plugins/organizations/CHANGELOG.md
+++ b/packages/plugins/organizations/CHANGELOG.md
@@ -1,5 +1,53 @@
# @objectstack/organizations
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [c2d4072]
+- Updated dependencies [78ab647]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [55d7aab]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [688052e]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json
index aa2a05bc35e..81222d65f64 100644
--- a/packages/plugins/organizations/package.json
+++ b/packages/plugins/organizations/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/organizations",
- "version": "18.0.0",
+ "version": "18.1.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 f40ca8fa585..4d6a8a9832e 100644
--- a/packages/plugins/plugin-approvals/CHANGELOG.md
+++ b/packages/plugins/plugin-approvals/CHANGELOG.md
@@ -1,5 +1,111 @@
# @objectstack/plugin-approvals
+## 18.1.0
+
+### Minor Changes
+
+- 7cacad5: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
+
+ Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
+
+
+
+ **BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
+
+ - **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
+ - **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
+ - **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
+ - **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
+ - **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
+ - **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
+
+ Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
+
+### Patch Changes
+
+- 9a06f0b: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-approvals): a reminder's `notified` counts each approver address once, not once per slate slot
+
+ Clause-②: no
+
+ `POST /api/v1/approvals/requests/:id/remind` (`ApprovalService.remind()`) answers `notified` as the number of reminders it handed to messaging: one per distinct approver address on the pending slate. A `per_group` slate lists a person once per group they resolved into, and `pending_approvers` keeps every one of those slots until the first vote, so a person placed in two groups was counted twice while messaging delivered them one reminder. They are now reminded once and counted once, the rule the opening `approval.requested` notice already applies.
+
+ A user id and the same account's email are still two addresses: the person holds two slots and receives two reminders, exactly as at the opening. Unchanged: who is reminded, each reminder's topic, payload, deep link and dedup key, the throttle, the submitter check, the `remind` audit row, and `position:*`-style literals, which still notify no one and add nothing to the count.
+- 28ff8a9: fix(plugin-approvals): every step of a multi-step approval names the person who submitted the run, not the previous step's approver
+
+ Clause-②: no
+
+ - **What was wrong.** This corrects the unreleased later-step submitter that PR #24527 introduced (an approval decision resumes its flow as the person who decided, `.changeset/24321-resume-as-decider.md`). Because the nodes after a decision now run as the decider, an approval step that opened after another step's decision recorded that decider as its `submitter_id`. On every later step the "I Submitted" list showed the request to the previous approver, recall and resubmit admitted the previous approver and refused the real submitter, and every notice meant for the submitter (`approval.returned`, `approval.request_info`, `approval.sla_breached`, `approval.approved`, `approval.rejected`) went to the previous approver.
+ - **Now.** Every step of a run records the user who triggered the run as its `submitter_id`, and as the actor of the step's `submit` timeline entry, whoever decided the steps before it. That holds on both `runAs` postures, after any number of decisions by different people, and on a step that a decision node reaches by routing around another. A run whose trigger resolved no user (a schedule, a write that carried no user) opens its later steps with no submitter, as it opens its first one.
+ - **Unchanged.** The decider still runs the nodes after their decision: their identity, permissions and attribution, as the resume-as-decider change ships them. The first step of every run, and every single-step approval, are unchanged. Compared with the last release nothing moves: later steps named the run's submitter there too, so recall and resubmit on a later step go back to the person they reached before.
+ - **`ApprovalService.openNodeRequest`.** A caller that passes `submitterId: null` now opens the request with no submitter. Before, `null` fell back to the calling context's user, the same as leaving `submitterId` out, which still falls back.
+- ba69b16: fix(plugin-approvals): the approvals inbox, the record's approval panel and the request page name the flow, the step and the record's object in the reader's language
+
+ Clause-②: no
+
+ - **What was wrong.** An approval request copies the flow's label and the Approval node's label when it opens (`process_label`, `current_step_label`), and the approvals door served that copy to every reader. A user whose locale is zh-CN, ja-JP or es-ES saw the authoring language, although the app's language pack carried `flows..label` and `flows..approvals..label` for those strings. The inbox row's `object_label` was the object's untranslated schema label.
+ - **Now.** `GET /api/v1/approvals/requests` (the inbox and the record's approval panel), `GET /api/v1/approvals/requests/:id` and the request a decision answers with serve `process_label`, `step_label` / `current_step_label` and `object_label` in the reader's locale (`Accept-Language`, else the `localization` settings). Each is read from the deployment's `i18n` service: `flows..label`, `flows..approvals..label` and `objects..label`. A bare `zh` reaches a `zh-CN` pack. The request get's step progress (`flow_steps`) names each step the same way.
+ - **Fallback.** A label the pack does not carry, a locale no pack holds, a read without a locale and a stack without an `i18n` service all get the label the request stored, exactly as before. An `en` reader of an app authored in English sees no change.
+ - **Unchanged.** The stored `process_label` and `current_step_label` columns keep the authored text they were written with: nothing is migrated or rewritten, and the generic data door, the list views and the one-tap action page still show the stored text. Who may read a request is unchanged. The `approval.requested` notification body is still English.
+ - **`ApprovalServiceOptions.i18nServiceSource`.** A new optional option: a lazy reader of the `i18n` service. `ApprovalsServicePlugin` wires it from the kernel's `i18n` service. A host that constructs `ApprovalService` by hand and leaves it out keeps the stored labels.
+- 58cd58f: fix(plugin-approvals): approval notifications reach each recipient in their own language
+
+ Every approval notification now hands the messaging service its sentence as catalog keys beside the English literal (`EmitInput.keyedText`). The messaging service renders it at delivery in each recipient's `sys_user.locale`, else the deployment default. This plugin's translation bundle gains the sentences in en, zh-CN, ja-JP and es-ES, in its `messages` face (`messages.approvalRequestedTitle` and the rest).
+
+ - **Which notifications.** `approval.requested`, `approval.reminder`, `approval.sla_breached` (to the approvers and to the submitter), `approval.approved` and `approval.rejected`, the two out-of-office notices (`approval.ooo_substituted`, `approval.ooo_skipped`), `approval.escalated`, `approval.reassigned` and `approval.returned` (the send-back and the auto-rejection). For `approval.comment`, `approval.request_info` and a send-back that carries the approver's note, only the headline is keyed. The body is that person's own words, delivered as written.
+ - **The record is named as before:** the object's label and the record id (`Deal D_123`), never a title. The label is rendered from the app's `objects..label` when its language pack holds one, else the schema label.
+ - **The English is unchanged.** The payload's `title` and `body` are the `en` catalog entries, byte for byte the sentences each notification carried before. A messaging service that does not render keyed text delivers them as it always did.
+ - **Unchanged.** Who is notified, each notification's topic, payload fields, deep link, dedup key and organization, and every channel's delivery behaviour apart from the text.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json
index 08c283bb99a..0befd93c3b4 100644
--- a/packages/plugins/plugin-approvals/package.json
+++ b/packages/plugins/plugin-approvals/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-approvals",
- "version": "18.0.0",
+ "version": "18.1.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 40c583ae195..2e382e06853 100644
--- a/packages/plugins/plugin-audit/CHANGELOG.md
+++ b/packages/plugins/plugin-audit/CHANGELOG.md
@@ -1,5 +1,91 @@
# @objectstack/plugin-audit
+## 18.1.0
+
+### Minor Changes
+
+- 3d8960c: feat(cli,plugin-audit): `os migrate audit-mail-bodies` also masks the mail bodies in the audit copies already moved to the `archive` datasource
+
+ Clause-②: yes (widening)
+
+ **Re-run `os migrate audit-mail-bodies --apply` on a deployment with an archive datasource.** The 18.0.0 command masked the hot audit tables only.
+
+ - **What was left in clear.** `sys_audit_log` declares `lifecycle.archive: { after: '90d', to: 'archive', keep: '7y' }`. Past the hot window, the lifecycle Archiver copies its rows to the datasource named `archive` as they are, then deletes them hot. A `sys_email` / `sys_identity_email` copy archived before the audit writer masked the mail body still holds `body_text` / `body_html` in clear there, password-reset, verification, magic-link and invitation links included, for as long as the archive keeps it. The engine never routes `sys_audit_log` to the archive, so the hot run could not reach those rows.
+ - **What changed.** After the hot run, the command reads the archive target each copy table declares (`sys_audit_log` only; `sys_activity` declares none) and runs the same mask, in the same dry-run or `--apply` mode, through that datasource's driver. Each archived row keeps every other column, `updated_at` included. The run is idempotent and its report is counts only. A hot copy masked first is archived masked, so one run covers both the copies already archived and the ones still to be.
+ - **Which archives it reaches.** The command now connects the managed datasources the stack config declares, through the same connect service `os serve` uses. An archive datasource is declared with `autoConnect: true`, since nothing routes objects to it. It is NOT examined, and the run reports `not-connected` and exits `1` rather than reporting a clean archive, in two cases. In the first, the archive is declared but this boot did not connect it; the entry carries the connect status and reason. In the second, the archive is a runtime datasource created in Setup → Datasources, which `os serve` connects and this command does not; declare it in the config's `datasources` and re-run.
+ - **What `none` means.** No datasource of that name is declared and connected on this boot, none was attempted, and no active runtime datasource row names one. That is all it checks. An archive a host registers in code outside the stack config is not reached and reads `none`. To mask it, declare it in the stack config's `datasources` with `autoConnect: true`.
+ - **The command's boot can now fail on a declared datasource.** A bound, mapped or boot-critical declared datasource that cannot connect now fails the command's boot with exit `1`, as it fails `os serve`'s. `OS_ALLOW_DRIVER_CONNECT_FAILURE` is the same escape.
+ - **Run it with the server stopped**, or re-run it once and read 0 to mask in both halves. A live server's Archiver can move a row between the hot run and the archive run.
+ - **New in the `--json` document:** an `archive` array, one entry per declared target: `{ datasource, objects, status, report?, connection? }`, with `status` one of `examined`, `none`, `not-connected` or `unknown`. The `report` key and its shape are unchanged.
+ - **New exports from `@objectstack/plugin-audit`:** `migrateArchivedMailLogBodyCopies(driver, logger, options)`, the runner over a datasource's driver, and an `objects` option on both runners, which limits a run to the named copy tables. Types `MailLogBodyArchiveDriver`, `MailLogBodyCopyObject` and `MailLogBodyMigrationOptions`.
+
+### Patch Changes
+
+- e5b3de0: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-audit): an @mention notification reaches only mentioned users who can read the comment's record
+
+ Clause-②: no (narrows who a notification reaches; no key, route or payload shape is added)
+
+ - **Recipients are judged by read access to the record.** The `sys_comment` mention writer now resolves each `mentions` entry to a user and asks, in that user's own envelope, whether they can read the record named by the comment's `thread_id`. The envelope comes from the resolver every request uses (`resolveUserAuthzGrants`). The user's organization claim is vetted under the tenancy posture in force (`vetOrganizationClaim`). The read goes through the evaluator every parent-derived read gate uses (`resolveReadableParentIds`). So the tenant wall, sharing, RLS and object permissions decide, as they do when that user opens the deep link. A user who cannot read the record gets no notification, and a `warn` line records their id and the reason (never the comment text).
+ - **Email entries resolve to a user first.** An email-shaped mention is looked up to its user and then checked like an id. An email that matches no user notifies nobody. Entries that expand an audience (`role:`, `team:`, `owner_of:`) name no single person and are no longer mentions.
+ - **Fail closed.** A comment whose thread names no record, a record that cannot be found, and a grant resolution that fails all withhold the notification. `installAuditWriters` takes the gate as `admitMentionRecipients`, and `AuditPlugin` wires it. A host that calls `installAuditWriters` without one emits no mention notifications and is told so once at `warn`.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [72518d2]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json
index f81a6c983d6..89ef8558add 100644
--- a/packages/plugins/plugin-audit/package.json
+++ b/packages/plugins/plugin-audit/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-audit",
- "version": "18.0.0",
+ "version": "18.1.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 fbe8f6d97ad..281addee2be 100644
--- a/packages/plugins/plugin-auth/CHANGELOG.md
+++ b/packages/plugins/plugin-auth/CHANGELOG.md
@@ -1,5 +1,250 @@
# Changelog
+## 18.1.0
+
+### Minor Changes
+
+- de9cd1f: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`
+
+ Clause-②: yes (widening)
+
+ - **Before.** An invitation naming a role outside the closed membership vocabulary was refused `400` with only a message (`ROLE_NOT_FOUND: `) and no `code`, so a client that branches on `error.code` (ADR-0112) could not recognise it. `update-member-role` already answered the same refusal with `code: 'ROLE_NOT_FOUND'`.
+ - **Now.** invite-member answers `400` with `code: 'ROLE_NOT_FOUND'` and the same message, naming the role, the body `update-member-role` gives for the same condition. The refusal still happens before any invitation row is written. This is the answer the invitation schema's `role` docstring and the authentication docs already promise.
+ - **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
+ - **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.
+- 78ab647: fix(plugin-auth): the auth product name binds to `branding.workspace_name` only where the settings service offers the `branding` namespace, so a walled boot no longer warns about it
+
+ Clause-②: no (this package stops reading a namespace `@objectstack/service-settings` no longer offers under a wall; that withholding, declared BREAKING in its changeset, is what narrows the walled deployment, and nothing here widens)
+
+ `AuthPlugin` binds the product name in sign-in emails, the authenticator app entry and the MCP sign-in to the `branding.workspace_name` setting. `@objectstack/service-settings` (same release) no longer offers the `branding` namespace under the `group` and `isolated` tenancy postures, so on such a deployment that read would fail on every boot and log `Auth: failed to apply branding.workspace_name: No settings manifest registered for namespace 'branding'.` at `warn`.
+
+ The binding is now made only when the settings service lists the `branding` namespace, the same test the runtime config makes. On a single-organization deployment a saved name, or one pinned with `OS_BRANDING_WORKSPACE_NAME`, applies as before.
+
+ **On a walled deployment (`group` / `isolated`) the auth product name now comes from the `OS_APP_NAME` chain alone.** Nothing is read from the `branding` namespace and nothing is logged. Two sources that used to name sign-in emails, the authenticator issuer and the MCP sign-in there stop doing so:
+
+ - `OS_BRANDING_WORKSPACE_NAME`. The settings service served it as an `env` value whatever the posture, and this plugin applied it.
+ - A `branding.workspace_name` row stored with no organization, for example one saved before the wall stood.
+
+ Migration, FROM → TO: `OS_BRANDING_WORKSPACE_NAME=Acme` → `OS_APP_NAME=Acme`. Set `OS_APP_NAME` to the name the deployment's sign-in emails and authenticator entries should carry. A deployment that set neither source sees no change.
+- 5872450: fix(plugin-auth)!: `POST /api/v1/auth/sys-oauth-application/register` refuses an application type the OAuth provider does not judge with `400 INVALID_REQUEST`, where it registered it as a `web` client
+
+ Clause-②: no (narrowing) — the register door accepted any `type` value and registered every one other than `web` / `native` as a `web` client with a secret; it now refuses each of those values with 400, so it accepts exactly `web`, `native` and no type.
+
+
+
+ **BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ The OAuth provider (`@better-auth/oauth-provider`) judges two application types, `web` and `native`, and this door forwards the type the Setup → OAuth Applications form chose as the provider's `application_type`. Any other value has no provider counterpart. The door used to register such a request as a confidential `web` client, with a client secret and the web redirect-URI rule, which is a different kind of client from the one the request asked for. It now refuses the request instead.
+
+ - **FROM:** a request whose `type` is present and is neither `web` nor `native` (`user-agent-based`, `public`, any other string including the empty string, or a value that is not a string) answered `200` and stored a `web` client, or was refused by the web rule (`OAUTH_REGISTER_FAILED`) for a loopback redirect URI.
+ - **TO:** the same request answers `400` with `error.code: 'INVALID_REQUEST'`. The message names the value (its first 64 characters) and the two accepted types, and says to choose `native` for an `http://localhost`, `http://127.0.0.1` or `http://[::1]` redirect URI. The provider is not called, nothing is stored, and no warning is logged.
+ - **Accepted:** `web` and `native` are forwarded as the provider's `application_type` (this release's Application Type entry), and the provider's own redirect-URI rule decides.
+ - **Unchanged:** A request with no `type`, or with `type: null`, registers a `web` client, the provider's default. The other refusals keep their codes (their statuses are this release's refusal-status entry). Clients already registered are not touched.
+ - **`@objectstack/platform-objects`:** only the code comment on the `create_oauth_application` action changes, to state this refusal; it ships in the package's JavaScript. The form offers only Web and Native (this release's register-form entry).
+ - **The fix for an integrator:** send `type: 'web'` for a client whose redirect URIs are https on a host that is not a loopback address, or `type: 'native'` for a client that also redirects to `http://localhost`, `http://127.0.0.1` or `http://[::1]`. Omitting `type` registers `web`.
+- 02e6d73: fix(cors)!: every CORS door reads one admission rule; a loopback origin beside a list is admitted on a development boot only; an all-host pattern is refused with credentials, as `'*'` is
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** — an admission narrowing at three doors, shipped as `minor` under the launch-window convention for accept-set narrowings. The governing text is `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an explicit origin list. It finishes what the `HonoServerPlugin` default change that `18.0.0` shipped (#24738) began.
+
+ - **One rule.** `@objectstack/plugin-hono-server` exports `resolveCorsAdmission`, and both transport doors read it: `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) and `@objectstack/hono`'s `createHonoApp`. One configuration now gets one answer at both. That one rule shared by two published doors is why the public surface grows: `resolveCorsAdmission` with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`, `isAllHostOriginPattern`, `OriginMatcherOptions`, and an optional second parameter on `createOriginMatcher`.
+ - **`createHonoApp`, before.** With no origin list (no `cors.origin` option and no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests, and a configured `'*'` with credentials on was accepted the same way. **Now** it answers as `HonoServerPlugin` does: no list admits no cross-origin caller (a loopback origin excepted, on a development boot), and `'*'` with credentials on is refused when the app is created.
+ - **`createHonoApp`'s `cors.origin` as one comma-separated string, before.** A `cors.origin` written as one comma-separated string of exact origins (`'https://a.example.com,https://b.example.com'`) admitted no origin. **Now** each listed origin is admitted, as `OS_CORS_ORIGIN` written the same way already was: a widening only toward what the operator listed, which the option's TSDoc already promised ("Comma-separated string or array").
+ - **Loopback beside a list, before.** A configured list also admitted every loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port), in every mode, at both doors that used the pattern matcher. **Now** that happens on a development boot only (`NODE_ENV` exactly `development`, read through `isDevelopmentBoot` from `@objectstack/core`). Elsewhere a list admits what it names; a list that names `http://localhost:*` still admits it. One door gains on a development boot: `createHonoApp` with a list of exact origins never admitted a loopback origin, and now does there, as `HonoServerPlugin` already did. `matchOriginPattern` now matches the pattern and nothing else, and `createOriginMatcher` takes an optional `{ developmentBoot }`, read from `NODE_ENV` when the matcher is created if it is not passed, so a consumer that applies the matcher outside the Hono pipeline answers as the doors do on the same boot.
+ - **All-host patterns, before.** Only a literal `'*'` was refused with credentials. **Now** a pattern whose host part is the bare `*` (`https://*`, `*://*`, `http://*:*`; `isAllHostOriginPattern`) is refused with credentials at both transport doors exactly as `'*'` is, and the message names the entry.
+ - **Auth's trusted origins, before.** `@objectstack/plugin-auth` tested the whole `OS_CORS_ORIGIN` value against `'*'` and then split it, so a `'*'` entry among real origins, or an all-host pattern, entered better-auth's trusted origins. **Now** each entry is judged on its own, and an entry that admits every host never enters the trusted set.
+ - **Unchanged.** Same-origin requests need no CORS answer. An exact origin or a pattern that names part of the host (`https://*.example.com`) is admitted as before, credentials included; the one exception is the comma-separated `cors.origin` string above, which now admits what it lists. `'*'` with credentials off is still served. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Headers, methods and max-age are unchanged. `OS_TRUSTED_ORIGINS` still admits nothing to CORS.
+
+ **What an operator does.** A host composing `createHonoApp` for a cross-origin front-end names its origins, in `OS_CORS_ORIGIN` or the `cors.origin` option. A deployment that reached the server from a loopback origin outside development names that origin in its list. A list holding `'*'` or an all-host pattern while credentials are on either names its origins or sets `OS_CORS_CREDENTIALS=false`.
+- 688052e: fix(objectql, plugin-auth)!: deleting an organization that still owns records is refused with 409 and a per-table count, before anything is deleted; an empty one is deleted whole, its teams and organization-admin grants with it; the engine never clears a tenant column
+
+ **The 18.0.0 defect this fixes.** `POST /api/v1/auth/organization/delete` ran better-auth's delete, which removes the organization's members, then its invitations, then the organization row, as three separate writes. The engine's delete cascade then applied the tenant column's `set_null` default to every row the organization still owned. On a database whose tenant column is NOT NULL (one that took the v18 organization-ownership ceremony), the delete answered 500 on `sys_team.organization_id` after the members and invitations were already gone, so the organization survived with no owner and nobody could delete it. On a database 18.0.0 created, where the column is still nullable, the delete answered 200 and left the organization's teams and records with `organization_id = NULL`, owned by nobody and visible to nobody. Every organization created through the door owns a default team, so on a NOT NULL database not even an empty organization could be deleted. Deployments that run `single` are not reached the same way: their one organization is the Default Organization, which the last-administrator guard answers first, and they cannot create another.
+
+ **What happens now.**
+
+ - An organization that still owns records is refused with `409` and the standard `DELETE_RESTRICTED` code. The body names each table and its row count, `dependents: [{ object, count }]`, and never a row's values. The refusal runs before better-auth's handler, so nothing is written, the caller's active organization included. A caller the organization does not let delete it still gets better-auth's own `403`, with no count.
+ - Append-only ledgers (objects declared `managedBy: 'append-only'`, such as `sys_activity`, `sys_audit_log` and `sys_email`) are history, not records. They never refuse the delete, and their rows are kept: every reference they hold to the organization (`organization_id` on `sys_activity` and `sys_email`, the attribution `tenant_id` on `sys_audit_log`) goes on naming the deleted organization, never NULL and never deleted. The delete writes activity rows of its own, so counting them would refuse every delete.
+ - Engine-owned rows (objects declared `managedBy: 'engine-owned'`) are treated the same way. The platform writes them as a consequence of records or activity, and an administrator cannot delete them over the data API: approval requests, approvers and tokens; automation runs; notifications, their receipts and deliveries; outgoing HTTP deliveries; mailboxes and mirrored mail; record shares and share links; import jobs; audience-binding suggestions; and organization-level settings values and their audit. They never refuse the delete, and they are kept, still naming the deleted organization.
+ - So engine-owned and append-only rows outlive the organization, and go on naming it. Under tenant scoping no organization reads them.
+ - An attribution column `organization_id` on an object whose tenancy is disabled is kept the same way: not counted, not cleared. A pointer to the organization, such as a session's or an API key's `active_organization_id`, is cleared as before.
+ - The Default Organization (slug `default`, or the deployment's only organization, never under an organization wall) is asked the last-administrator guard's question first, and answers `403 PERMISSION_DENIED` while deleting it would leave no platform administrator. Once the guard admits, it is counted like any other organization, before better-auth deletes anything, except that only its members and invitations are left out: its teams and grants are not removed, so they count, and the delete is refused `409 DELETE_RESTRICTED` while it holds them.
+ - An organization that owns nothing but its own auth structure is deleted whole: its members and invitations (better-auth), and its teams, their members and the organization-admin grants its owners and admins hold (plugin-auth, from `beforeDeleteOrganization`). Any other grant scoped to the organization counts as a record.
+ - The request runs as one engine transaction and rolls back on any error answer, so a delete either removes all of that or none of it. On a datasource whose driver has no transactions, the refusals are the guarantee, because all of them run before the first write: the record count above, and, for the Default Organization, the last-administrator guard, whose `403` is now asked before better-auth deletes any member (it used to arrive after the members and invitations were gone).
+ - Under all of this, the engine itself refuses to delete a `sys_organization` row while any row still names it in its tenant column, whatever path reaches the delete: `409 DELETE_RESTRICTED` with the same `dependents` list, which better-auth's door now carries to the wire too. It counts each table rather than reading its rows. An organization is deleted by id only: a delete matching organizations by a filter (`multi: true`) is refused with `409 DELETE_RESTRICTED` naming the by-id route, because only the by-id delete runs this referential pass. A tenant column is never set to NULL.
+ - `ObjectQL.organizationOwnedRowCounts(organizationId, { exclude })` answers, per object, how many rows name an organization in their tenant column. It is the question the refusal asks.
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (the organization delete's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** deleting an organization that still owned records answered `500` after deleting its members and invitations (tenant column NOT NULL), or `200` with its teams and records orphaned to `organization_id = NULL` (tenant column nullable, an 18.0.0-born database). A direct engine delete of such a `sys_organization` row did the same.
+ - **TO:** both answer `409 DELETE_RESTRICTED`, with `dependents: [{ object, count }]` naming every table that still holds rows. Nothing is deleted or changed.
+ - **Fix:** delete the organization's records, or move them to another organization, then delete the organization. The refusal names the tables to look at. Business units, positions and sharing rules count as records. Activity, audit and mail-log rows do not, nor do engine-owned rows (approval state, runs, deliveries, shares, settings values).
+ - **Seed datasets:** on a composition with the organizations runtime (`@objectstack/organizations`) and an app that ships seed datasets, a new organization receives that seed replay at creation, so it owns records from its first moment and its delete is refused until they are removed. The refusal lists them. Delete them (or move them) like any other records, then delete the organization. Measured on the showcase: a fresh organization owns 132 seeded rows in 19 tables.
+ - **Already orphaned on 18.0.0?** Rows an earlier organization delete set to `organization_id = NULL` are not touched by this release. Organization-less rows are what the v18 organization-ownership ceremony (`os migrate organization-ownership`) attributes or reports.
+- 4bf079c: fix(plugin-auth): `os migrate apply`'s `sys_account.issuer` pre-flight report names each colliding row's stored issuer instead of `(none)`
+
+ Clause-②: yes (widening)
+
+ `probeAccountIdentityCollisions` read `sys_account` through the engine's `find`. The engine answers declared columns only, and the shipped `sys_account` no longer declares `issuer`, so the report `os migrate apply --allow-destructive` prints when it refuses the `sys_account.issuer` drop labelled every colliding row's issuer `(none)`, in the human report and in the `--json` document's `preflight.collisions[].issuers` alike. `os migrate account-issuer` read the same database through the driver and printed the stored values. The issuers are what an operator needs to decide which colliding row survives.
+
+ - **`@objectstack/plugin-auth`.** When its source offers the storage door (`getDriverForObject`, as the `objectql` engine both CLI doors pass does), the probe reads `sys_account` through the driver the engine routes that name to, and through nothing else: a missing driver is refused, never answered from the engine's `find`. A source that offers `find` alone is still read as handed. A new option, `onTableAbsent`, reads a missing `sys_account` table as no rows and reports that it did. Without it, a missing table is refused like any other failed read.
+ - **Widened public surface (`@objectstack/plugin-auth`).** The package entry re-exports the pre-flight module, so it now publishes two new types, `AccountTableStorage` (`getDriverForObject`) and `AccountTableDriver`, and a new option, `AccountIdentityPreflightOptions.onTableAbsent`. `probeAccountIdentityCollisions` now accepts `AccountTableStorage | AccountIdentityReadEngine` as its source. Every earlier caller still type-checks, and nothing narrows.
+ - **`@objectstack/cli`.** `os migrate account-issuer` reads through the probe's reader instead of its own, so it and `os migrate apply` read one table one way. Its output is unchanged.
+
+ **Unchanged.** The collision verdict (`ok`, the colliding keys, row ids, users, counts), the `apply` refusal and its exit code. A database whose `sys_account` has no `issuer` column reports each row as `(none)`, as before.
+
+### Patch Changes
+
+- c2d4072: fix(plugin-auth): an administrator's impersonation session is not held by the target's forced password reset or password expiry (#24762)
+
+ Clause-②: no
+
+ Impersonating a user who must change their password used to strand the administrator: the impersonation session was refused `403 PASSWORD_RESET_REQUIRED` on every protected route, and the console showed the target's change-password screen. An impersonation session is minted from the administrator's own session, which has passed its own posture check, so it is not the user's password sign-in, and a `credential`-scope posture holds the password sign-in alone (`AUTH_GATE_SCOPES`).
+
+ - **What moves.** On a session that carries `impersonatedBy`, the session gate (`user.authGate`, stamped in `customSession`) now shows only the first `account`-scope posture the user holds. `PASSWORD_RESET_REQUIRED` and `PASSWORD_EXPIRED` no longer hold it. `MFA_REQUIRED` still does, so an impersonation session of a user who owes MFA enrolment is still refused `403 MFA_REQUIRED`, including when the user also owes a password change.
+ - **Unchanged.** The user's own sign-in stays held until they change the password. The forced reset's revocation (`revokeSessionsAndKeys`, on by default) still ends every session of the user, impersonation sessions included. API keys, the print door's refusal of an impersonation session, and the impersonation door's own check of the administrator's posture are unchanged.
+- fbb42d9: fix(plugin-auth): the Console's OAuth Application Type reaches the provider as `application_type`, so a Native client can register a loopback redirect URI (#24773)
+
+ Clause-②: no — the declared type now takes effect under the OAuth provider's own redirect-URI rule, an authentication behaviour the maintainer ruled on; nothing published widens.
+
+ `POST /api/v1/auth/sys-oauth-application/register` (the Setup → OAuth Applications form) forwarded the form's Application Type as `type`. The provider's `/oauth2/create-client` body declares `application_type` (`web` | `native`) and no `type`, so the value was dropped and every client was judged and stored as `web`. A `native` client therefore could not register a loopback redirect URI such as `http://localhost:4001/callback`, and its `sys_oauth_application.type` read `web`.
+
+ - **Now.** `web` and `native` are forwarded as `application_type`, and the provider's own rule decides. A `native` client may use http only on exactly `localhost`, `127.0.0.1` or `[::1]` (any port), and is stored as `native`. A `web` client still needs an https URI on a non-loopback host.
+ - **Refused.** Any other type, including the form's `user-agent-based` and `public`, has no `application_type` in the provider. The register route refuses it with `400 INVALID_REQUEST` and registers nothing (this release's breaking entry for the route). To use a loopback redirect URI, register the client as `native`.
+ - **Unchanged.** No type at all is `web`. Refusals keep the codes and statuses they had: `400 OAUTH_REGISTER_FAILED`, with `error.details.upstreamError` `invalid_redirect_uri` when the redirect-URI rule refuses.
+- 950f949: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-auth): a refused OAuth application registration answers the status the refusal declared, not 500 (#24773)
+
+ Clause-②: no
+
+ `POST /api/v1/auth/sys-oauth-application/register` answered most refusals from better-auth as `500 OAUTH_REGISTER_FAILED`. better-auth raises each refusal as an `APIError` whose HTTP status is the number `statusCode` (`400`) and the name `status` (`'BAD_REQUEST'`). The route read only a numeric `status`, so a named refusal fell through to 500. Two examples are a redirect URI the provider refuses, such as `http://localhost:4001/callback` on a `web` client (`invalid_redirect_uri`), and a request with no session (`UNAUTHORIZED`).
+
+ - **Now.** The route answers the refusal's own status: `400` for the redirect-URI refusal and `401` for the missing session. The code stays `OAUTH_REGISTER_FAILED`, and `error.details.upstreamError` still carries the provider's discriminator (`invalid_redirect_uri`).
+ - **Unchanged.** A throw that declares no status, such as a storage fault while the client is persisted, still answers `500`. The body-schema refusal keeps answering `400`. Which redirect URIs a client may register is unchanged: a `web` client still needs an `https` URI on a non-loopback host.
+- ad8cf92: fix(plugin-auth): the vendored auth library records and keys on the client address the server established (#24799)
+
+ Clause-②: no
+
+ better-auth reads a request's client address with its own reader, and three things are keyed on what it reads: a session's `ip_address` (and the `login` / `logout` audit rows copied from it), the address on the `login_failed` / `account_locked` audit rows, and the key of better-auth's own per-address rate limiter. That reader did not follow `server.trustProxy`. It now follows the same rule as the `allowed_ip_ranges` allow-list and `last_login_ip`: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address. When neither is available, better-auth is handed no address.
+
+ **For operators:**
+
+ - Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack. Without it, sessions and audit rows record the proxy's own address, and better-auth's per-address rate limit counts every client behind the proxy as one client.
+ - With `server.trustProxy` declared, a forwarded chain (`client, proxy`) now resolves to its first entry. Before, better-auth read a multi-entry header as no address.
+ - A request whose client address cannot be established records no address, and better-auth's rate limiter counts it in one shared bucket.
+
+ **For hosts:** `AuthManager.handleRequest` now drops any `x-objectstack-client-address` request header a caller sends. The platform writes that header itself, from the address it established, and better-auth reads no other header for the client address. A programmatic `auth.api.*` call is handed whatever headers its caller passes. Route a client's request through `handleRequest` instead.
+- 865d2c8: fix(plugin-auth): the phone OTP and invitation SMS read their template row in the organization the send resolves to
+
+ Clause-②: no
+
+ `sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The phone OTP and invitation SMS are deployment-level sends whose callbacks carry no organization, so their template row is now read in the organization that act resolves to, the tenancy service's `defaultOrgId()` (ADR-0093 D3), the same answer the membership reconciler binds a new user to:
+
+ - **`single`:** the Default Organization's `sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, sms, locale)` decides the text, as an operator authored it in Setup.
+ - **`isolated` and `group`:** no organization is resolved, so no template row is read and the built-in text is sent. No organization's row decides the text a user receives.
+ - **No tenancy service, or a failed resolution:** the built-in text. A template outage still never blocks a send.
+ - **Unchanged:** the locale ladder (the recipient's stored locale, then the deployment default, then `en`), how a deactivated or blank row passes its rung on, the built-in texts themselves, and the rendered holes.
+- 2941a19: fix(plugin-auth): every in-process auth bridge judges the request it re-dispatches on the same client-address facts as the request it serves (#24877)
+
+ Clause-②: no
+
+ Eight auth routes answer by re-dispatching a request to better-auth in-process: `POST /api/v1/auth/admin/remove-user`, `/admin/has-permission`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/send-verification-email` and `/sys-session/revoke`. `AuthManager.handleRequest` establishes the re-dispatched request's client address by the client-address trust rule: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`, and otherwise the address is the connection's peer address. Each route handed `handleRequest` the request alone, without the connection it arrived on. Six of them also rebuild the request before re-dispatching it, and the rebuilt request did not carry the forwarding headers.
+
+ - **Now.** Each route hands `handleRequest` the connection it was called on, as the main auth mount does. Each rebuilt request carries the incoming request's `X-Forwarded-For` and `X-Real-IP`. Without a declared trusted proxy, the re-dispatched request is judged on the connection's peer address, whatever those headers say. With `server.trustProxy` declared, it is judged on the forwarded client address, the same address as the request the route serves.
+ - **Unchanged.** What each route accepts or refuses, and every answer it gives. `resolveClientAddress` decides exactly as before. The main auth mount, and the `allowed_ip_ranges` allow-list in front of every auth route, already judged on the connection.
+- 03966ce: fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
+
+ `@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
+
+ Clause-②: no (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
+
+ - **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
+ **TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
+ - **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
+ **TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
+ - **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
+ **TO:** it answers `400` with `error: 'invalid_grant'`.
+ - **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
+ **TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
+ - **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
+ **TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
+ - **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
+ **TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
+ - **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
+ **TO:** the same `401` also clears the stale session cookies.
+ - **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
+ - **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
+ - **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
+ - **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
+ - **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [865d2c8]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0603bfa]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [f802213]
+- Updated dependencies [86d2360]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [58cd58f]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [d68aab6]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [a39389e]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4f97aeb]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-messaging@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/rest@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json
index f44652d3895..c739febbc61 100644
--- a/packages/plugins/plugin-auth/package.json
+++ b/packages/plugins/plugin-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-auth",
- "version": "18.0.0",
+ "version": "18.1.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 fdb8c567065..c3fc4676750 100644
--- a/packages/plugins/plugin-dev/CHANGELOG.md
+++ b/packages/plugins/plugin-dev/CHANGELOG.md
@@ -1,5 +1,85 @@
# @objectstack/plugin-dev
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [f90ea67]
+- Updated dependencies [c2d4072]
+- Updated dependencies [78ab647]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [6440cf1]
+- Updated dependencies [72518d2]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [55d7aab]
+- Updated dependencies [98c3d00]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [f802213]
+- Updated dependencies [86d2360]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [9532e27]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [aaee084]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [b289090]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [f46ef4d]
+- Updated dependencies [3782b2d]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [2e242bb]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4f97aeb]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/plugin-security@18.1.0
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/service-storage@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/plugin-hono-server@18.1.0
+ - @objectstack/rest@18.1.0
+ - @objectstack/driver-memory@18.1.0
+ - @objectstack/service-i18n@18.1.0
+ - @objectstack/service-realtime@18.1.0
+ - @objectstack/account@18.1.0
+ - @objectstack/setup@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json
index 8932bdc6a52..4c622cb932a 100644
--- a/packages/plugins/plugin-dev/package.json
+++ b/packages/plugins/plugin-dev/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-dev",
- "version": "18.0.0",
+ "version": "18.1.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 ab0afd65cc4..64e80388bf8 100644
--- a/packages/plugins/plugin-email/CHANGELOG.md
+++ b/packages/plugins/plugin-email/CHANGELOG.md
@@ -1,5 +1,79 @@
# @objectstack/plugin-email
+## 18.1.0
+
+### Patch Changes
+
+- abd5acd: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-email, service-sms): Setup's "Send test email" and "Send test SMS" send to the signed-in admin when the request names no recipient (#24767)
+
+ Clause-②: no
+
+ The Mail and SMS settings pages offer a Send test button and no recipient input, so the console's request carries no `to`. "Send test email" fell back to the From address — often a no-reply mailbox nobody reads — and with From empty it refused, asking for a "to" address the page has no field for. "Send test SMS" refused every press the same way.
+
+ With no `to` in the request, the test now goes to the person who pressed the button: `mail/test` sends to the caller's own account email and `sms/test` to the caller's own phone number, each read from the caller's own `sys_user` row. A `to` in the request body still wins, as before. When the caller's account has no address or number, or the account cannot be read, the action refuses and says that nothing was sent. A phone number is on an account only where phone sign-in is enabled, so on other deployments "Send test SMS" still needs a `to` in the request body.
+
+ `sms/test` no longer reports a send through the log-only transport (the namespace's default `None (log only)` provider) as a success. It answers `ok: false` with severity `warning`, as `mail/test` already does for its log transport. When the form names no delivering provider and the transport the deployment configured at boot carries the test, the success message names that transport instead of saying the message went "via log".
+
+ **Unchanged.**
+
+ - The request and result shapes of `POST /api/settings/mail/test` and `POST /api/settings/sms/test`: no key, route or setting is added.
+ - Which provider and credentials a test uses: the saved values overlaid with the form's unsaved edits, as before.
+ - Saving the Mail form still judges a required field only when the save writes it, or writes a key its visibility depends on.
+- fccbd9d: feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered
+
+ Clause-②: yes (widening: an optional member on `IEmailService`)
+ Metadata-protocol: none
+
+ - **The contract.** `IEmailService` (`@objectstack/spec/contracts`) gains one optional member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. `true` means the active transport hands mail to a real delivery provider. `false` means no mail leaves the process: sends still resolve and still write their audit row. A `log` transport is never delivering, in any environment. The answer is read at call time and follows a transport swapped in at runtime.
+ - **Absent means unknown.** The member is optional, so every existing implementation stays valid. A consumer must never read an absent member as delivering: a flow whose success depends on a mail reaching someone treats absent the same as `false` and keeps its explicit refusal, so a service that is merely wired is never mistaken for one that delivers.
+ - **The implementation.** `@objectstack/plugin-email`'s `EmailService.isConfigured()` answers `false` while its transport is the `LogTransport` (the default when no mail transport is configured) and `true` for any other transport. It follows the transport the plugin rebuilds from the `mail` settings, so saving an SMTP or API-provider configuration turns it `true` without a restart.
+ - **Unchanged.** Sending, templates, persistence and the boot warning for the `log` transport behave as before. No consumer reads the member yet.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/formula@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json
index 99e003706bc..c5f553b1a83 100644
--- a/packages/plugins/plugin-email/package.json
+++ b/packages/plugins/plugin-email/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-email",
- "version": "18.0.0",
+ "version": "18.1.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 99be5452493..c7e4b3e0111 100644
--- a/packages/plugins/plugin-hono-server/CHANGELOG.md
+++ b/packages/plugins/plugin-hono-server/CHANGELOG.md
@@ -1,5 +1,62 @@
# @objectstack/plugin-hono-server
+## 18.1.0
+
+### Minor Changes
+
+- 02e6d73: fix(cors)!: every CORS door reads one admission rule; a loopback origin beside a list is admitted on a development boot only; an all-host pattern is refused with credentials, as `'*'` is
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** — an admission narrowing at three doors, shipped as `minor` under the launch-window convention for accept-set narrowings. The governing text is `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an explicit origin list. It finishes what the `HonoServerPlugin` default change that `18.0.0` shipped (#24738) began.
+
+ - **One rule.** `@objectstack/plugin-hono-server` exports `resolveCorsAdmission`, and both transport doors read it: `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) and `@objectstack/hono`'s `createHonoApp`. One configuration now gets one answer at both. That one rule shared by two published doors is why the public surface grows: `resolveCorsAdmission` with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`, `isAllHostOriginPattern`, `OriginMatcherOptions`, and an optional second parameter on `createOriginMatcher`.
+ - **`createHonoApp`, before.** With no origin list (no `cors.origin` option and no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests, and a configured `'*'` with credentials on was accepted the same way. **Now** it answers as `HonoServerPlugin` does: no list admits no cross-origin caller (a loopback origin excepted, on a development boot), and `'*'` with credentials on is refused when the app is created.
+ - **`createHonoApp`'s `cors.origin` as one comma-separated string, before.** A `cors.origin` written as one comma-separated string of exact origins (`'https://a.example.com,https://b.example.com'`) admitted no origin. **Now** each listed origin is admitted, as `OS_CORS_ORIGIN` written the same way already was: a widening only toward what the operator listed, which the option's TSDoc already promised ("Comma-separated string or array").
+ - **Loopback beside a list, before.** A configured list also admitted every loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port), in every mode, at both doors that used the pattern matcher. **Now** that happens on a development boot only (`NODE_ENV` exactly `development`, read through `isDevelopmentBoot` from `@objectstack/core`). Elsewhere a list admits what it names; a list that names `http://localhost:*` still admits it. One door gains on a development boot: `createHonoApp` with a list of exact origins never admitted a loopback origin, and now does there, as `HonoServerPlugin` already did. `matchOriginPattern` now matches the pattern and nothing else, and `createOriginMatcher` takes an optional `{ developmentBoot }`, read from `NODE_ENV` when the matcher is created if it is not passed, so a consumer that applies the matcher outside the Hono pipeline answers as the doors do on the same boot.
+ - **All-host patterns, before.** Only a literal `'*'` was refused with credentials. **Now** a pattern whose host part is the bare `*` (`https://*`, `*://*`, `http://*:*`; `isAllHostOriginPattern`) is refused with credentials at both transport doors exactly as `'*'` is, and the message names the entry.
+ - **Auth's trusted origins, before.** `@objectstack/plugin-auth` tested the whole `OS_CORS_ORIGIN` value against `'*'` and then split it, so a `'*'` entry among real origins, or an all-host pattern, entered better-auth's trusted origins. **Now** each entry is judged on its own, and an entry that admits every host never enters the trusted set.
+ - **Unchanged.** Same-origin requests need no CORS answer. An exact origin or a pattern that names part of the host (`https://*.example.com`) is admitted as before, credentials included; the one exception is the comma-separated `cors.origin` string above, which now admits what it lists. `'*'` with credentials off is still served. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Headers, methods and max-age are unchanged. `OS_TRUSTED_ORIGINS` still admits nothing to CORS.
+
+ **What an operator does.** A host composing `createHonoApp` for a cross-origin front-end names its origins, in `OS_CORS_ORIGIN` or the `cors.origin` option. A deployment that reached the server from a loopback origin outside development names that origin in its list. A list holding `'*'` or an all-host pattern while credentials are on either names its origins or sets `OS_CORS_CREDENTIALS=false`.
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json
index cdb8b82a4fe..c4f53bf5fb3 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": "18.0.0",
+ "version": "18.1.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 ba69fe69f57..b5bc6d34b68 100644
--- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md
+++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md
@@ -1,5 +1,29 @@
# @objectstack/plugin-pinyin-search
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [7532ea3]
+- Updated dependencies [72518d2]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [644e4b2]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [db9c02a]
+ - @objectstack/core@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json
index 3b29c8fd1f2..6304595597c 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": "18.0.0",
+ "version": "18.1.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 cab5f69ea5e..6fe0d222689 100644
--- a/packages/plugins/plugin-security/CHANGELOG.md
+++ b/packages/plugins/plugin-security/CHANGELOG.md
@@ -1,5 +1,282 @@
# @objectstack/plugin-security
+## 18.1.0
+
+### Minor Changes
+
+- 94842ba: feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.
+
+ - **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
+ - **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
+ - **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
+ - **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
+ - **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
+ - **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.
+
+ **What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
+- f8c5fb3: feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered)
+
+ Clause-②: no
+
+ **Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe.
+
+ **Now.**
+
+ - `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope.
+ - `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`.
+
+ **What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright.
+
+ **Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection.
+- b289090: fix(plugin-security)!: an update or delete of a `controlled_by_parent` detail asks for edit access to its master on every write shape (ADR-0055 §3)
+
+ Clause-②: no (narrowing)
+
+
+
+ ADR-0055 §3 says editing or deleting a detail requires edit access to its master, and it names no write shape as exempt. The master-detail write gate decided one shape of write to a `controlled_by_parent` detail without asking that question. It now asks it: the gate collects the masters of the detail rows the write would reach, as the caller's own read returns them, and runs on each master the check a by-id write runs. One master the caller may not edit refuses the whole write, and nothing is written. On a write made on a user's behalf, both principals are asked, as on a by-id write.
+
+ **BREAKING** for a caller that relied on that shape to write details under a master it may not edit. Shipped as `minor` under the launch-window convention for accept-set narrowings.
+
+ - **Before.** That shape of write changed or removed details under a master the caller may not edit, bounded only by what the caller could read and, for an organization member, by the platform ownership floor.
+ - **Now.** It answers the refusal a by-id write of the same detail answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. A write whose rows all sit under masters the caller may edit lands as before and writes the same rows. A row the caller cannot read is not judged and is not written.
+ - **Also refused.** A caller whose read of the detail is itself refused gets that read's refusal for this shape of write, because the masters of rows it cannot read cannot be established. A write reaching more than 10,000 readable rows answers `INVALID_FILTER` (400), the refusal the same count already answers.
+ - **Unchanged.** Inserts and by-id updates and deletes; system contexts; the detail's read scope; the platform ownership floor; and every object that does not declare `controlled_by_parent`.
+
+ **What to do.** Grant the caller edit access on the master, or narrow the write to details under masters the caller may edit.
+- f46ef4d: fix(plugin-security)!: an update of a `controlled_by_parent` detail also asks for edit access on the master its body names (ADR-0055 §3)
+
+ ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master, and it reads a create's master from the master id in the incoming body. A write to a `controlled_by_parent` detail can involve more than one master, and for one shape of write the master-detail write gate asked the master-edit question of one of them only. It now asks it of every master the write involves, with the same check and in the same order as before for the one it already asked. One master the caller may not edit refuses the whole write, and nothing is written. On a write made on a user's behalf, both principals are asked, as on every other detail write.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** for a caller that relied on that shape of write landing while one of the masters it involves is a master the caller may not edit. Shipped as `minor` under the launch-window convention for accept-set narrowings.
+
+ - **Before.** That shape of write was decided on one of the masters it involves, and landed when the caller could edit that one, whatever the other.
+ - **Now.** It answers the refusal a write under the other master answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. A write whose masters the caller may all edit lands as before and writes the same rows. A write that reaches no row involves no master and is not refused.
+ - **Unchanged.** Inserts and deletes; every write that involves only the master its rows already sit under; system contexts; the detail's read scope; the platform ownership floor; and every object that does not declare `controlled_by_parent`.
+
+ **What to do.** Grant the caller edit access on every master the write involves, or keep the write within masters the caller may edit.
+- 3782b2d: fix(plugin-security)!: a batch create of a `controlled_by_parent` detail asks for edit access on the master of every row it names, and an update no longer empties a detail's master reference (ADR-0055 §1, §3)
+
+ ADR-0055 §3 says creating, editing or deleting a detail requires edit access to its master, and it reads a create's master from the master id in the incoming body. A batch create carries one body per row. The master-detail write gate read a create's master only from a body that is a single record, so a batch create asked about no master at all. That covers the engine's array insert and `insertMany`, and the bulk-create and import doors that call them. The gate now asks about the master of every row the batch names, with the same check a single create runs, once per master and in row order. One master the caller may not edit refuses the whole batch, and nothing is written. On a write made on a user's behalf, both principals are asked, as on every other detail write.
+
+ ADR-0055 §1 derives a detail's access from exactly one required master. For a master reference that required-field validation does not judge, an update whose body set the reference to null landed. It stored a detail that no principal's read can match. The gate now refuses it as it already refuses a create that names no master.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** for a caller that relied on either write landing. Shipped as `minor` under the launch-window convention for accept-set narrowings.
+
+ - **Before.**
+ - A batch create of a detail landed whatever masters its rows named, including masters the caller may not edit.
+ - An update that set a master reference, of a kind required-field validation does not judge, to null landed and stored the null.
+ - **Now.**
+ - That batch create answers the refusal a single create of the offending row answers: `PERMISSION_DENIED` (403), naming the detail and the master-edit reason. The partial-success `insertMany` refuses the whole call rather than reporting that row.
+ - A batch whose rows' masters the caller may all edit lands as before and writes the same rows.
+ - That update answers `MISSING_REQUIRED_FIELD` (422) and writes nothing. Where required-field validation does judge the reference, it still answers the update, as before.
+ - **Unchanged.**
+ - Single creates, and every update and delete that does not empty the master reference.
+ - A row naming no master, which the same rules as before answer.
+ - System contexts, the detail's read scope and the platform ownership floor.
+ - Every object that does not declare `controlled_by_parent`.
+ - The import door degrades a refused batch to one create per row. There, the rows under masters the caller may edit still land, and the other row is refused on its own.
+
+ **What to do.** Grant the caller edit access on every master a batch names, or keep each batch within masters the caller may edit. To move a detail to another master, write that master's id, never null.
+- 2e242bb: fix(plugin-security)!: a delegated operation runs only in an organization the delegator is a member of, on a walled posture
+
+ ADR-0090 D10 bounds an on-behalf-of operation by the delegator's scope. The operation runs in the live principal's active organization, and the delegator context is resolved in that same organization, on the stated assumption that the agent and its delegator are in the same organization. Nothing checked that assumption. The delegated-principal prologue (`resolveOperationPrincipals`) now checks it with `vetOrganizationClaim`, the one rule for "a membership backs this organization": under `isolated` or `group`, when the on-behalf-of context carries an active organization the delegator is not a member of, every operation is refused before any gate runs.
+
+ Clause-②: no (narrowing) — on a walled posture, an operation of an on-behalf-of context whose active organization the delegator's membership does not back is refused `403 PERMISSION_DENIED`, where it used to run.
+
+
+
+ **BREAKING** (the security middleware's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** under `isolated` or `group`, an operation (read, insert, update or delete) of an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of ran, answered by the gates the middleware composed for the two principals.
+ - **TO:** the same operation is refused `403` with `code: 'PERMISSION_DENIED'` before any gate runs. The message names the organization and the delegator.
+ - **Unchanged:** the `single` posture, an on-behalf-of context with no active organization (it keeps the ADR-0123 D2 answers), a context without `onBehalfOf`, a dangling delegator (still refused as before), and system contexts. The `/mcp` OAuth door, the one producer of an on-behalf-of context in the platform, vets the request's organization against the same membership, so the contexts it builds are never refused by this rule.
+ - **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand (a plugin, a service, an agent runtime) must set its `tenantId` to an organization the delegating user is a member of, or leave it unset.
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- f90ea67: fix(plugin-security): the `position_name_fold_grant` warning says the same-name grant is removed in the next major version, and what holders lose then
+
+ Clause-②: no (a runtime warning's wording plus one added field in its log payload; no accept set, served shape, resolution result or error code moves)
+
+ - **What it was.** A position resolves the permission set that has its own name even when the position's definition does not list that set in `permissionSets`. `plugin-security` has warned about this since 17.3 (`position_name_fold_grant`), once per position. The warning said the grant was "in force and ungoverned" and asked for `permissionSets: ['P']` or a rename. It did not say that this name-based grant is being retired, or what happens to holders when it is.
+ - **What changed.** The warning now says three things. The set is in force for holders of position `P` only because the two share a name. This name-based grant is deprecated and is removed in the next major version: from then on, holders of `P` do not receive set `P` unless `P`'s definition names it. To keep the grant, declare `permissionSets: ['P']` on position `P`; if the shared name is a coincidence, rename one of the two. The structured payload adds `removedIn: 'next_major'`. `event`, `position` and `permissionSet` are unchanged.
+ - **Why only a warning in 18.x.** No ADR-0087 conversion can carry this retirement: the platform cannot write `permissionSets` into a sealed managed position definition (ADR-0131 D6). So 18.x ships the warning, and the removal of the name-based grant waits for 19.0.
+ - **Unchanged.** Every resolution result. A holder of `P` still receives set `P` in 18.x. The warning still fires under the same conditions, including for a position bound to a different set that also shares its name with one. It stays silent for a declared pair, a baseline set and a position whose name matches no set, and it still fires once per position name until the next restart.
+ - **What to do.** For each position named in a `position_name_fold_grant` warning, add the same-named set to that position's `permissionSets` before upgrading to the next major version. Setup's Permission Sets field and the set's Assigned Users panel then show the binding as well.
+- 6440cf1: fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id
+
+ Clause-②: no (no accept set moves: the value a declared formula computes on read changes; no key, column, write path or authorization answer moves)
+
+ - **Before.** `sys_user_permission_set`'s record title (`display_title`, the object's `nameField`) composed the stored user id, so Setup's list of a user's permission sets and the assignment's record page read like `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap → crm_edit`.
+ - **Now.** The title names the person: the user's `name`, or their `email` where the name is blank, then the set, as in `Alice Liddell → crm_edit`. The name is read one hop through `user_id`, under the reader's own permissions (the ADR-0079 addendum on one-hop formula titles). A reader who may not read that user, and a grant whose user no longer exists, get no title (`null`), never the user's name or email; a renderer then falls to the object's `titleFormat`, `{user_id} → {permission_set}`. No column is stored, and the title is still neither sortable nor searchable.
+ - **Cost.** A read of `sys_user_permission_set` that computes its formulas (no field projection, or a projection naming `display_title`) now issues one batched `sys_user` read per page. The grant read every authenticated request makes is not one of them: it names the columns it decides on, so it computes no title, and the request's authorization prelude stays 8 reads in 4 round trips for a principal holding a direct grant.
+- 98c3d00: fix(plugin-security): a platform operator's tenant-scoped insert from a session with no active organization is refused 403 naming the missing organization, as a member's is, instead of a 500
+
+ Clause-②: no (no accept set moves: the insert was refused before and is refused now, with the ADR-0123 D2 answer in place of an internal error)
+
+ - **Before.** Under `group` or `isolated`, a platform operator (`admin_full_access`) whose session had no active organization got `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` for a create on a tenant object whose posture lets it cross the organization wall (`access.default: 'private'`). The operator crosses the wall there, so the wall let the insert through, and the engine then refused the row as a system write with no organization.
+ - **After.** That create gets `403 PERMISSION_DENIED` with the message a member gets in the same state. The message names the missing active organization and tells the user to join or select one.
+ - **Unchanged.** Nothing was written before and nothing is written now. The refusal judges the row as it is about to be stored, the same row the engine's own organization check reads, so it covers exactly the inserts that answered 500 and no insert that was admitted. These are as before:
+ - a row a `beforeInsert` hook places in an organization;
+ - the operator's reads and updates across organizations;
+ - inserts from an operator with an active organization;
+ - inserts into objects that opted out of tenancy;
+ - system contexts (which still meet the engine's own refusal);
+ - every `single` answer.
+- 5050442: fix(objectql,plugin-security): an update's re-point of `organization_id` that the Layer 0 wall admits is stored as sent, not silently dropped
+
+ Clause-②: yes (widening) — an update re-point of `organization_id` the Layer 0 wall admits is now stored instead of silently dropped, and an in-process `strictReadonlyWrites: true` update naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
+
+ **What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an update's `organization_id`. The Layer 0 write wall admitted C, on the payload and on the row about to be stored. Then the engine's static-`readonly` update strip took it, because the platform's injected tenant column is `readonly: true`, and the row stayed in A. The update answered 2xx, by id and by predicate alike, and the only signals were a `droppedFields` entry and a WARN. An object that declares its own writable `organization_id` stored the same re-point, so the app objects on the injected column were the inconsistent half. This is the update half of the insert fix shipped beside it.
+
+ **What changed.**
+
+ - **`@objectstack/objectql`.**
+ - `ObjectQL.update` reads `PostHookWriteImageCheck.judgesOrganization` (reached as `OperationContext.postHookWriteImageCheck`) once per update, for a non-system caller, and hands that one value to every static-`readonly` pass: the pre-hook hide pass, the by-id strip, the predicate strip, and the stored view the `readonlyWhen` strips and the master-detail settlement read.
+ - Where it is set, and the object's `organization_id` is the platform's injected column on an object the insert strip also judges, those passes leave the caller's re-point on the row. The wall then stores it as sent where it admits it and refuses it where it does not.
+ - The decision is one helper, shared with `ObjectQL.insert`, so the two write verbs spare exactly the same objects.
+ - The member's published doc now covers updates. No member is added.
+ - **`@objectstack/plugin-security`.**
+ - Step 3.7 sets `judgesOrganization` on the stored-image seam it composes for a single-payload update, only where it composes the wall's own parts in, as it already did for an insert.
+ - It does not set the member on a business-`check`-only seam, on a crossing platform operator's update (which gets no wall seam), or on an update whose payload is an array (step 3.7 composes no seam for one).
+ - The wall's admit/refuse set is unchanged: a re-point outside the caller's scope is still refused `403 PERMISSION_DENIED`, and so is an emptied one (`null` or `''`).
+
+ **What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and the engine's own gates (the wall, the strip and the strict flag) refuse nothing they accepted before.
+
+ - **The re-point is stored as sent.** Under `group`, an update naming another organization of the caller's membership set moves the row there, by id and by predicate. Under `isolated`, an update naming the active organization is stored as before.
+ - **No longer reported.** Such a re-point is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
+ - **`strictReadonlyWrites` stores it.** An in-process `engine.update(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
+ - **`beforeUpdate` hooks see it.** On a walled update, `beforeUpdate` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
+ - **A member with no active organization.** Under `group`, such a member re-points within its membership set the same way, and outside it is refused.
+ - **An on-behalf-of update (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The re-point is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted re-point was taken and the row kept its organization.
+ - **Outcomes that read the stored organization now follow the admitted value.** An organization-scoped unique key, a validation rule, a `readonlyWhen` / `requiredWhen` predicate, and a hook that reads `organization_id` now see the organization the row is moved to, not the one it used to keep. So a re-point that collides with an organization-scoped unique key in its new organization is refused there, as it already was on an object that declares its own `organization_id`; before, the re-point was dropped and the write reported success.
+ - **Only the platform's injected column on an app object is spared.** A `sys_` object, a platform-internal `managedBy` bucket (`engine-owned`, `append-only`, `better-auth`), and an object whose author declares its own `organization_id` with `readonly: true` keep the update strip: the value is still taken, reported, and refused under strict, exactly as before. The organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) declare a writable `organization_id` the strip never took, so their re-points keep meeting their own guards exactly as before.
+ - **Where no wall judges the column, nothing changes.** The re-point is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, a platform operator crossing the wall on its own behalf, and `preserveAudit`, whose update-only exemption never reaches `organization_id`.
+ - **The `update`-mode `validate()` preview is unchanged.** It runs no middleware, so it holds no seam: it still reports an admitted re-point as dropped while the write stores it.
+- 2b1382a: fix(objectql,plugin-security): an insert's explicit `organization_id` that the Layer 0 wall judges is stored as sent, not replaced by the active organization
+
+ Clause-②: yes (widening) — `PostHookWriteImageCheck` gains the optional published member `judgesOrganization`, and an in-process `strictReadonlyWrites: true` insert naming an organization the wall admits is now stored instead of refused `ERR_READONLY_FIELD_REJECTED`.
+
+ **What was wrong.** Under the `group` posture, a member whose active organization is A can name C, another organization it belongs to, in an insert's `organization_id`. The Layer 0 write wall admitted C. Then the engine's static-`readonly` strip took it, because the platform's injected tenant column is `readonly: true`, and the driver stamped A. The insert answered 2xx with the row in A, single and array alike. The only signals were a `droppedFields` entry and a WARN. Under `isolated`, a caller that named its own active organization saw the same strip and report, for a value that ended up stored anyway.
+
+ **What changed.**
+
+ - **`@objectstack/objectql`.**
+ - `PostHookWriteImageCheck` (reached as `OperationContext.postHookWriteImageCheck`) gains an optional member, `judgesOrganization?: boolean`. An enforcement layer sets it when the seam's `evaluate` holds every inserted row's `organization_id` to the tenant wall.
+ - `ObjectQL.insert` reads that member to decide. When it is set and the object's `organization_id` is the platform's injected tenant column, a non-system caller's explicit `organization_id` stays out of the pre-hook withhold and out of the static-`readonly` strip. The injected column is told apart by the registry's provenance reader; an authored column keeps the strip. The value is then stored as sent where the wall admits it, and refused by the wall where it does not.
+ - When the member is absent or `false`, both passes run exactly as before. That covers a seam that carries only a business `check`, a stack without an enforcement layer, and `validate()`, which runs no middleware.
+ - **`@objectstack/plugin-security`.**
+ - Step 3.7 sets the member only where it composes the wall's own parts into an insert's stored-image seam.
+ - It does not set the member on a business-`check`-only seam, on a crossing platform operator's seam, or on any update.
+ - The wall's admit/refuse set is unchanged: an organization outside the caller's scope is still refused `403 PERMISSION_DENIED`.
+
+ **What a caller sees.** Every change below is a widening. None of them admits a `(caller, organization)` pair the wall does not already admit, and nothing accepted before is refused now.
+
+ - **The optional member.** `PostHookWriteImageCheck.judgesOrganization` (`OperationContext.postHookWriteImageCheck`) is new published surface on `@objectstack/objectql`.
+ - **The value is stored as sent.**
+ - Under `group`, an insert naming an organization of the caller's membership set is stored there.
+ - Under `isolated`, an insert naming the active organization is stored as before.
+ - **No longer reported.** Such an organization is no longer reported as dropped: not in `droppedFields`, not through the `onFieldsDropped` listener, and not by the read-only WARN.
+ - **`strictReadonlyWrites` stores it.** An in-process `engine.insert(..., { strictReadonlyWrites: true })` naming such an organization is now stored, where it used to be refused `ERR_READONLY_FIELD_REJECTED`: strict refuses exactly what the strip takes. No REST door can set that flag.
+ - **`beforeInsert` hooks see it.** On a walled insert, `beforeInsert` hooks now see the caller's `organization_id`, because the value will be stored. Before, it was withheld from them.
+ - **A member with no active organization.** Under `group`, such a member with a non-empty membership set can now insert a row naming one of those organizations: it is stored there. It used to be refused `403 PERMISSION_DENIED` under ADR-0123 D2, because the value was taken and the row then named no organization. A row naming none is still refused under D2, and one naming an organization outside the set is still refused by the wall.
+ - **An on-behalf-of insert (ADR-0090 D10).** Take a platform operator who crosses the wall and acts for a walled delegator. The named organization is held to the delegator's wall: stored as sent where that wall admits it, refused `403` where it does not. Before, an admitted value was taken and the active organization stored.
+ - **An explicit empty value in an array insert.** A row naming `organization_id: null` or `''` is no longer taken, reported, or refused under strict. It is still stored as the active organization, because the driver's stamp fills an empty value.
+ - **Only the platform's injected column is spared.** On an object whose author declares its own `organization_id` with `readonly: true`, a caller's value is still taken, reported, and refused under strict, exactly as before. The wall still judges that column. The author's declaration is what keeps the strip, and the injected column is told apart by the registry's provenance reader (`resolveInjectedColumnProvenance`).
+ - **Where no wall judges the column, nothing changes.** The value is still taken and reported, and strict still refuses it. That includes `single`, a composition without the security plugin, a tenancy-disabled object, and a platform operator crossing the wall on its own behalf.
+- 9532e27: fix(plugin-security): a `controlled_by_parent` detail's read scope requires object-level read on its master (ADR-0055 §3)
+
+ Clause-②: no
+
+ ADR-0055 §3 says reading a detail requires read access to its master, and §4 says a member who cannot read the master can neither read nor by-id-write a detail under it. The master-derived read scope of a `controlled_by_parent` object scoped the master's rows, but it did not ask whether the principal may read the master object at all. It now asks first, through the predicate `canReadObject` answers. A principal the master's own read door refuses at the object gate gets an empty master set, so it reads no detail under that master.
+
+ - **Every door that composes the scope.** The engine read door (`find`, `findOne`, `count`), the delegator half of an on-behalf-of read (ADR-0090 D10), `getReadFilter`, and every hop of a chain all answer this way.
+ - **The write doors follow.** A by-id update or delete of such a detail answers the read door's not-found (`RECORD_NOT_FOUND`, 404), as it does for any row the principal cannot read. A predicate update or delete matches no such detail.
+ - **The engine already gave this answer.** Its ADR-0139 `ReadableIds` leaf asks `canReadObject` before it scopes a parent's rows. The id-list derivation and the leaf now agree.
+ - **Unchanged.** A principal that may read the master object keeps its previous answer, whether or not its master rows are scoped. So do a system context, a super-user grant, and an on-behalf-of read whose delegator may read the master.
+- aaee084: fix(plugin-security): a `controlled_by_parent` detail write asks its master the object-level question the master's own write door asks (ADR-0055 §3)
+
+ Clause-②: no
+
+ ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master. The master-edit gate's object-level leg asked only for an object-level `update` grant on the master. It now asks what the master's own write door asks of an `update` on the master, with that door's inputs: the master's security posture must resolve, the capabilities the master requires for `update` (ADR-0066 D3) must be held, and the grant is asked under the master's posture (ADR-0066 D2). A principal the master's own write door refuses at its object gate can no longer write a detail under that master.
+
+ - **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg.
+ - **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. A principal that also cannot read the master, on an on-behalf-of write too, now gets this `PERMISSION_DENIED` on insert where it got the reference check's `VALIDATION_FAILED`. An insert under a master object that does not resolve answers `PERMISSION_DENIED` where it answered `OBJECT_NOT_FOUND` (404).
+ - **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json
index 9439c48950f..0d8ad6a483c 100644
--- a/packages/plugins/plugin-security/package.json
+++ b/packages/plugins/plugin-security/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-security",
- "version": "18.0.0",
+ "version": "18.1.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 e7762816850..1ab04e5d286 100644
--- a/packages/plugins/plugin-sharing/CHANGELOG.md
+++ b/packages/plugins/plugin-sharing/CHANGELOG.md
@@ -1,5 +1,99 @@
# @objectstack/plugin-sharing
+## 18.1.0
+
+### Minor Changes
+
+- 7cacad5: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
+
+ Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
+
+
+
+ **BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
+
+ - **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
+ - **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
+ - **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
+ - **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
+ - **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
+ - **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
+
+ Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [72518d2]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [688052e]
+- Updated dependencies [6b656bf]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json
index 8efce0cac93..ab66477b8e9 100644
--- a/packages/plugins/plugin-sharing/package.json
+++ b/packages/plugins/plugin-sharing/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-sharing",
- "version": "18.0.0",
+ "version": "18.1.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 8fce61c37ac..9ca5d074325 100644
--- a/packages/plugins/plugin-webhooks/CHANGELOG.md
+++ b/packages/plugins/plugin-webhooks/CHANGELOG.md
@@ -1,5 +1,94 @@
# @objectstack/plugin-webhooks
+## 18.1.0
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- 3fbef41: fix(plugin-webhooks): a webhook delivery the outbox could not record is now reported at `error`, once per webhook, with what was lost and how to fix it
+
+ Clause-②: no
+
+ - **What was wrong.** When the `sys_http_delivery` write for a webhook event failed, the auto-enqueuer logged one bare `WARN [webhook-auto-enqueuer] enqueue failed` line per event. The event was lost: it is never sent, retried or redeliverable, because no row exists. The record write that produced the event had already answered 200, and the webhook still read active in Setup, so nothing else looked wrong. On PostgreSQL every record-update delivery was lost this way.
+ - **What changed.** The first lost delivery for a webhook is logged at `error`. The line names the webhook, says the event is lost and why nothing else shows it, and says where the cause is (the record's meta) and what to restore. Later lost events for the same webhook are logged at `debug`, each with its `eventId`. The `error` is said again only after a delivery for that webhook was recorded in between. A logger without `error` still receives the line at `warn`. Bulk (`data.records.*`) deliveries are reported the same way.
+ - **Unchanged.** Successful deliveries, the delivery rows, the parked-subscription path and every export. This change does not fix the PostgreSQL loss itself: that cause is upstream of the webhook plugin and is fixed separately.
+- 19e5338: fix(plugin-webhooks): a declared webhook that the deployment can never deliver is reported once at boot, at `error`, with the consequence and the fix
+
+ Clause-②: no
+
+ The webhook auto-enqueuer reads record events only from the realtime service. A stack with `requires: ['webhooks']` and no `'realtime'` therefore never delivers a declared webhook.
+
+ - **Before.**
+ - The boot logged one `WARN [webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available`. The line named no webhook, gave no consequence and gave no fix.
+ - Every record write still answered 200, the declaration still read active, and no delivery row was ever written.
+ - **After.** When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before.
+ - **Unchanged.**
+ - Every deployment whose auto-enqueuer runs.
+ - A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line. It turned the enqueuer off itself.
+ - `os validate` and `os build`: they still accept a stack that declares webhooks without these tokens.
+ - The activation door (`POST /api/v1/security/_activation/webhook/NAME`): it answers exactly as before.
+ - **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [865d2c8]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0603bfa]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [58cd58f]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [d68aab6]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [a39389e]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-messaging@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json
index b78d669e2b0..b335d8cf77c 100644
--- a/packages/plugins/plugin-webhooks/package.json
+++ b/packages/plugins/plugin-webhooks/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/plugin-webhooks",
- "version": "18.0.0",
+ "version": "18.1.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 f9819a3eddf..007a9e95e43 100644
--- a/packages/qa/dogfood/CHANGELOG.md
+++ b/packages/qa/dogfood/CHANGELOG.md
@@ -1,5 +1,126 @@
# @objectstack/dogfood
+## 0.0.49
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [9a06f0b]
+- Updated dependencies [f90ea67]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [c2d4072]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [abd5acd]
+- Updated dependencies [6b9e6df]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [73a2b52]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [6440cf1]
+- Updated dependencies [72518d2]
+- Updated dependencies [e5b3de0]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [28ff8a9]
+- Updated dependencies [55d7aab]
+- Updated dependencies [98c3d00]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [3fbef41]
+- Updated dependencies [ba69b16]
+- Updated dependencies [865d2c8]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [3d8960c]
+- Updated dependencies [0715fac]
+- Updated dependencies [ea08ce5]
+- Updated dependencies [19e5338]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0603bfa]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [b811c73]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [688052e]
+- Updated dependencies [5d27d32]
+- Updated dependencies [6b656bf]
+- Updated dependencies [7f99616]
+- Updated dependencies [9532e27]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [58cd58f]
+- Updated dependencies [58cd58f]
+- Updated dependencies [69712d5]
+- Updated dependencies [d68aab6]
+- Updated dependencies [aaee084]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [b289090]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [f46ef4d]
+- Updated dependencies [3782b2d]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [a39389e]
+- Updated dependencies [2e242bb]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/plugin-security@18.1.0
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/plugin-sharing@18.1.0
+ - @objectstack/plugin-approvals@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/plugin-webhooks@18.1.0
+ - @objectstack/service-messaging@18.1.0
+ - @objectstack/service-storage@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/plugin-email@18.1.0
+ - @objectstack/service-datasource@18.1.0
+ - @objectstack/plugin-audit@18.1.0
+ - @objectstack/example-showcase@0.3.23
+ - @objectstack/connector-mcp@18.1.0
+ - @objectstack/connector-openapi@18.1.0
+ - @objectstack/connector-rest@18.1.0
+ - @objectstack/mcp@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/plugin-pinyin-search@18.1.0
+ - @objectstack/service-analytics@18.1.0
+ - @objectstack/trigger-record-change@18.1.0
+ - @objectstack/trigger-schedule@18.1.0
+ - @objectstack/verify@18.1.0
+ - @objectstack/example-crm@4.0.101
+ - @objectstack/example-multi-package@0.0.8
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 0.0.48
### Patch Changes
diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json
index 7e48fa66e03..a4e3a294f5c 100644
--- a/packages/qa/dogfood/package.json
+++ b/packages/qa/dogfood/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/dogfood",
- "version": "0.0.48",
+ "version": "0.0.49",
"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 7f8bc74fae4..35d22527661 100644
--- a/packages/qa/downstream-contract/CHANGELOG.md
+++ b/packages/qa/downstream-contract/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/downstream-contract
+## 0.0.47
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 0.0.46
### Patch Changes
diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json
index a10a3806107..686721bacee 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.46",
+ "version": "0.0.47",
"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 9a5870389fe..7c85c841cee 100644
--- a/packages/qa/http-conformance/CHANGELOG.md
+++ b/packages/qa/http-conformance/CHANGELOG.md
@@ -1,5 +1,14 @@
# @objectstack/http-conformance
+## 0.1.9
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [644e4b2]
+- Updated dependencies [dfe60e3]
+ - @objectstack/core@18.1.0
+
## 0.1.8
### Patch Changes
diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json
index 60335f6506a..808cf0e4e4c 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.8",
+ "version": "0.1.9",
"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 3609faabb27..6c0871fafe3 100644
--- a/packages/rest/CHANGELOG.md
+++ b/packages/rest/CHANGELOG.md
@@ -1,5 +1,97 @@
# @objectstack/rest
+## 18.1.0
+
+### Minor Changes
+
+- eddffc3: feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language
+
+ Clause-②: yes (widening)
+
+ - **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
+ - **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
+ - **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
+ - **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
+ - **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
+
+### Patch Changes
+
+- f802213: fix(rest): a lookup cell in the list export names the record by the target object's title field (#24944)
+
+ Clause-②: no
+
+ `GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote a lookup, master-detail, user or tree cell from the lookup field's own `displayField`. Without one, it took the first key of a fixed list (`name`, `title`, `label`, `full_name`, …) found on the expanded record. It never read the target object's `nameField`. So a contact that declares `nameField: 'full_name'` and has no `name` column exported as its job `title` ("VP of Engineering" instead of "John Smith").
+
+ - **Now.** The export reads each target object's title field once per export, by ADR-0079's `resolveDisplayField`: the `nameField`, then the deprecated `displayNameField` alias. The registry sets that pointer on every object it holds. The cell shows that field's value from the expanded record. This applies to csv, xlsx and json.
+ - **Precedence.** The lookup field's own `displayField` still wins when it is set. The key list is used only when the target resolves no title field, or when its schema cannot be read.
+ - **Field-level security.** When the caller cannot read the target's title field, the `$expand` read leaves it off the record. The cell then shows the record's id. Another column, such as the job title, no longer stands in for the name.
+ - **Visible change.** A target with a literal `name` column and a different declared title now exports the declared title. For example, an account with `nameField: 'display_title'` exports its display title instead of `name`.
+ - **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes.
+- 86d2360: fix(rest): a boolean cell in the list export is written in the caller's language (#24945)
+
+ Clause-②: no
+
+ `GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX / JSON) wrote every boolean or toggle cell as `是` or `否`, whatever the caller read. The header row of the same file follows the caller's locale, so an English caller got "Converted" and "Do Not Call" over Chinese cells.
+
+ - **Now.** A boolean or toggle cell is written in the words the import template already uses for the caller's locale: `yes` / `no`, or `是` / `否` for a `zh*` locale. The locale is the one the header row is translated to: `Accept-Language`, then `?locale=`, then the deployment's default locale. The header and the cells always read one language. This applies to csv, xlsx and json.
+ - **No locale at all.** When the request names no locale and the deployment declares no default, the cells read `yes` / `no`, the same words the import template writes then.
+ - **Re-import.** A file exported in either language re-imports its boolean column: the importer reads `yes` / `no` and `是` / `否`.
+ - **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a boolean cell changes, and a Chinese caller's file is byte-for-byte what it was.
+- 4f97aeb: fix(rest): the API docs viewer escapes the request-derived openapi URL for the attribute it lands in
+
+ The `GET {base}/docs` viewer page builds the sibling openapi URL from the request and places it into a `data-url="…"` attribute of a script element. It interpolated that value into the attribute with no escaping, so a character the attribute context interprets reached the served markup as itself rather than as the matching HTML entity. The value is now escaped for its attribute context (ruling 3C on #25040: every outlet that renders a value into HTML escapes it by the context it lands in): the five characters a quoted attribute or element text interprets become their entities, and a browser decodes each back to the original character for the page script. A URL carrying none of those characters is served byte-identically.
+
+ Clause-②: no
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/service-package@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/rest/package.json b/packages/rest/package.json
index 499ccba96c3..badf89e8333 100644
--- a/packages/rest/package.json
+++ b/packages/rest/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/rest",
- "version": "18.0.0",
+ "version": "18.1.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 fb8923388ba..bd8432c47d0 100644
--- a/packages/runtime/CHANGELOG.md
+++ b/packages/runtime/CHANGELOG.md
@@ -1,5 +1,144 @@
# @objectstack/runtime
+## 18.1.0
+
+### Minor Changes
+
+- 4cef6e2: fix(runtime)!: `POST /api/v1/packages/:id/adopt-orphans` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as the other package write doors already do
+
+ ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and every other door that changes a package (`POST /packages` with `overwrite`, `PATCH /packages/:id`, `PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuses such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The orphan-adoption door never asked the rule, and the protocol's `reassignOrphanedMetadata` asks none of its own: it rebinds every package-less `sys_metadata` row to whatever id it is handed. So a `manage_metadata` holder's adoption into such a package answered `200` and bound the environment's loose items into it. The door now asks the same rule after the capability gate and the walled refusal, and before the protocol is resolved.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** `POST /api/v1/packages/:id/adopt-orphans` naming a read-only package answered `200` with `{ success, reassignedCount, reassigned, targetPackageId }` and rebound every package-less `sys_metadata` row to that package.
+ - **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and rebinds nothing. Because the refusal comes before the protocol is resolved, a deployment whose protocol cannot adopt answers the same `422` for a read-only package where it answered `501`.
+ - **Unchanged:** a caller without `manage_metadata` is still refused `403` first, and under an `isolated` or `group` tenancy posture a caller below the operator rung is still refused `403 PERMISSION_DENIED` first. A package an organization owns adopts as before, and an id nothing installed still reaches the protocol, which binds the orphans to it as before.
+ - **The fix for an integrator:** a package the deployment ships is managed by the deployment, not over this API. Adopt the loose items into a package you own (create one with `POST /api/v1/packages` if none exists).
+
+### Patch Changes
+
+- 94842ba: feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently.
+
+ - **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all.
+ - **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone.
+ - **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row.
+ - **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer".
+ - **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one.
+ - **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth.
+
+ **What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization.
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [f90ea67]
+- Updated dependencies [c2d4072]
+- Updated dependencies [78ab647]
+- Updated dependencies [6b9e6df]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [6440cf1]
+- Updated dependencies [72518d2]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [55d7aab]
+- Updated dependencies [98c3d00]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [eee65af]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [ea08ce5]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [b811c73]
+- Updated dependencies [f802213]
+- Updated dependencies [86d2360]
+- Updated dependencies [688052e]
+- Updated dependencies [5d27d32]
+- Updated dependencies [6b656bf]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [9532e27]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [aaee084]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [b289090]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [f46ef4d]
+- Updated dependencies [3782b2d]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [2e242bb]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4f97aeb]
+- Updated dependencies [db9c02a]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/plugin-security@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-protocol@18.1.0
+ - @objectstack/driver-sql@18.1.0
+ - @objectstack/driver-turso@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/service-datasource@18.1.0
+ - @objectstack/rest@18.1.0
+ - @objectstack/driver-memory@18.1.0
+ - @objectstack/driver-sqlite-wasm@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/service-cluster@18.1.0
+ - @objectstack/service-i18n@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index ebddb6e53ef..2833f5baad9 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/runtime",
- "version": "18.0.0",
+ "version": "18.1.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 5c3d608e38a..8b21862d9a4 100644
--- a/packages/sdui-parser/CHANGELOG.md
+++ b/packages/sdui-parser/CHANGELOG.md
@@ -1,5 +1,9 @@
# @objectstack/sdui-parser
+## 18.1.0
+
+No changes in this release.
+
## 18.0.0
No changes in this release.
diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json
index f13c28667d9..81aa9fc49c0 100644
--- a/packages/sdui-parser/package.json
+++ b/packages/sdui-parser/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/sdui-parser",
- "version": "18.0.0",
+ "version": "18.1.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 76d447109c2..61fab24f454 100644
--- a/packages/services/service-analytics/CHANGELOG.md
+++ b/packages/services/service-analytics/CHANGELOG.md
@@ -1,5 +1,41 @@
# Changelog — @objectstack/service-analytics
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json
index 609796dd745..6604603a341 100644
--- a/packages/services/service-analytics/package.json
+++ b/packages/services/service-analytics/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-analytics",
- "version": "18.0.0",
+ "version": "18.1.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 bce430b6a72..2e287693523 100644
--- a/packages/services/service-automation/CHANGELOG.md
+++ b/packages/services/service-automation/CHANGELOG.md
@@ -1,5 +1,81 @@
# @objectstack/service-automation
+## 18.1.0
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- 7532ea3: feat(objectql): a caller value that a stored field's `expression` replaced is reported through `onFieldsDropped` / `droppedFields` under the new `DroppedFieldsEvent.reason` value `stored_expression`
+
+ Clause-②: yes (widening)
+
+ The engine computes a stored (non-`formula`) field's declared `expression` on every write, so a value the caller sends for such a field never stands. Until now nothing said so. `onFieldsDropped` stayed silent, so the PATCH and `/batch` doors answered with no `droppedFields`, and a flow's `update_record` reported success over a row that holds the computed value instead of the one sent.
+
+ - **New `reason` value.** `DroppedFieldsEventSchema.reason` (`@objectstack/spec/data`) gains `stored_expression`. It means the field is a stored column the engine computes from its `expression`, and the caller's value was replaced by the computed one. The field IS written, so `computed` (a `formula` with no column, whose key is dropped) would be wrong for it. The four existing values are unchanged.
+ - **When it is reported.** On insert (single, batch, and `insertMany`'s per-row `droppedFields`), on update by id, and on a predicate update, in every context, `isSystem` included. It is reported only when the caller sent the field and the value stored differs from the caller's value. A client that sends the computed value (the console's line-item grid) or no value is told nothing. A value another strip already took, such as a stored expression field that is also `readonly`, is reported once, under that strip's reason.
+ - **`strictReadonlyWrites` is unchanged.** The maintainer's ruling 「甲」 says only the loud half ships in 18.x. A strict write whose value for such a field differs from the computed one is NOT refused: it completes with the computed value, and the replacement is reported through `onFieldsDropped` under strict as well. A refusal is runtime behaviour that no conversion can carry, so the strict refusal of a `stored_expression` replacement is deferred to 19.0. A strict write that also carries a refusable drop (`readonly`, `readonly_when`, `primary_key`, `computed`) is refused as before, for those drops only, and fires no listener.
+ - **Flow step warning.** `@objectstack/service-automation` words the new reason. Its warning says the requested value(s) were not stored, rather than that the field was not written, and its closing sentence says the write succeeded with the computed value(s).
+
+ **For consumers that branch on `reason`.** A `Record` table fails its typecheck until it has a `stored_expression` row. That is the intended signal: add the row. A client running behind the server should treat a reason it does not recognise as "not applied", never as one of the known reasons.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-protocol@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/formula@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json
index ac588c9f1a3..532e5afa720 100644
--- a/packages/services/service-automation/package.json
+++ b/packages/services/service-automation/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-automation",
- "version": "18.0.0",
+ "version": "18.1.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 a9f0b35cd6e..24b68197f7f 100644
--- a/packages/services/service-cache/CHANGELOG.md
+++ b/packages/services/service-cache/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/service-cache
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/observability@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json
index 94857d93c08..0d8e1c9fb65 100644
--- a/packages/services/service-cache/package.json
+++ b/packages/services/service-cache/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-cache",
- "version": "18.0.0",
+ "version": "18.1.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 d9c4d720e2c..dfa097012e5 100644
--- a/packages/services/service-cluster-redis/CHANGELOG.md
+++ b/packages/services/service-cluster-redis/CHANGELOG.md
@@ -1,5 +1,37 @@
# @objectstack/service-cluster-redis
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-cluster@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json
index 25451ea47ad..e1c79df05c2 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": "18.0.0",
+ "version": "18.1.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 a230a848c33..7727f3bded0 100644
--- a/packages/services/service-cluster/CHANGELOG.md
+++ b/packages/services/service-cluster/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/service-cluster
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json
index ba181e01fa8..899ac00022b 100644
--- a/packages/services/service-cluster/package.json
+++ b/packages/services/service-cluster/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-cluster",
- "version": "18.0.0",
+ "version": "18.1.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 659d8e2b0fd..cf8fa4f06a3 100644
--- a/packages/services/service-datasource/CHANGELOG.md
+++ b/packages/services/service-datasource/CHANGELOG.md
@@ -1,5 +1,191 @@
# @objectstack/service-external-datasource
+## 18.1.0
+
+### Minor Changes
+
+- 6b9e6df: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(service-datasource): Setup → Datasources: Test answers truthfully for the platform's primary datasource and for drivers that cannot introspect, and Sync on the primary datasource refuses with the true reason
+
+ Clause-②: yes (widening: the published `ExternalDatasourceServiceConfig`, exported from the package entry, gains one optional member, the `liveDriver` callback; no status code, error code or accepted value moves on any route, and Test's answer keeps its existing keys)
+
+ - **What was wrong.** The Datasources page listed the primary datasource (`default`) as connected, which is true. Its Test then answered `200 { ok: false }` and Sync answered `400`, both saying the datasource "has no registered driver to introspect". The primary datasource's driver is registered under the driver's own name, not under `default`, and both doors looked it up by `default`. A connected `memory` datasource also got `ok: false` from Test, because Test was an introspection round trip and that driver cannot introspect.
+ - **Test, `POST /api/v1/datasources/:name/test`.** For the primary datasource, Test now finds the live driver the way the connection service's own teardown does and answers its health check: `{ ok: true, latencyMs }`. It does not list the platform database's tables. For a driver with no schema introspection (`memory`, `mongodb`), Test answers the health check too, `{ ok: true, latencyMs }` with no `tableCount`, instead of `ok: false`. A driver that fails its health check answers `ok: false`. Every other datasource is tested exactly as before (an introspection round trip with `tableCount`), and so is a datasource with no live driver.
+ - **Sync on the primary datasource.** `GET …/remote-tables`, `POST …/object-draft`, the import (`POST …/external/tables/:remote/import`) and the catalog refresh still refuse `default`, with the same status and code as before (`400 EXTERNAL_DATASOURCE_ERROR`, or `EXTERNAL_IMPORT_ERROR` on the import). The message now gives the true reason: the primary datasource is the platform's managed storage, not a federation source, so there are no remote tables to list, draft or import from it. Its tables are never introspected.
+ - **Unchanged.** Sync on every other datasource, including a managed runtime one, answers exactly as before.
+ - **New, optional: `ExternalDatasourceServiceConfig.liveDriver`.** `(datasource) => driver | undefined` returns the live driver registered for a datasource, resolved by the primary datasource's naming rule. `ExternalDatasourceServicePlugin` wires it. Test reads it to reach the primary datasource and drivers without schema introspection.
+
+ What to do: nothing, if the plugin builds the service. If you build `ExternalDatasourceService` yourself, pass `liveDriver` to get the new Test answers; without it, Test stays the introspection round trip, as before.
+- 7f99616: fix(driver-sql): a SQLite file whose bytes are not a SQLite database fails `connect()` at once, naming the file, and a development boot no longer moves it aside and replaces it with an empty database (#24960)
+
+ `SqlDriver.connect()` runs `PRAGMA auto_vacuum` as the connection's first statement. On a file that is not a SQLite database (plain text, another format, a file encrypted by another engine) SQLite answers `SQLITE_NOTADB`, which `connect()` logged as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and swallowed. It resolved over a file it could not read. In production the first query failed later. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) answered the failed probe by stepping down to wasm SQLite, whose open renamed the file to `.corrupt-`, wrote an empty database in its place and read `connected`, under a warning that blamed the native addon.
+
+ `connect()` now refuses such a file with a `SqliteDatabaseFileError` (`code: 'SQLITE_NOTADB'`, `filename`, a message naming the file), as it already did for `SQLITE_CANTOPEN`, in every mode and before anything is written. A declared datasource's boot prints `datasource '': connect failed — `. `resolveSqliteDriver` classifies `SQLITE_NOTADB` as a file failure and re-throws it instead of stepping down, so the operator's file is left exactly as it was found. A real database file, an empty file and an absent file connect as before, and a native addon that cannot load still steps down to wasm SQLite.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (`SqlDriver.connect()`'s accept set narrows): a connect that resolved over a SQLite file that is not a database now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
+
+ - **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot the file was renamed to `.corrupt-` and an empty database was written in its place.
+ - **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_NOTADB'`, naming the file; the file is untouched.
+ - **Fix:** point the datasource at a SQLite database file, or move the file aside yourself (an absent file is created empty on the next connect).
+- d6a5fa3: fix(driver-sql): a SQLite database whose header SQLite cannot read fails `connect()` at once, naming the file, and a dangling symbolic link as the database's directory is refused on the boot that writes, as the read-only boot already refused it (#25019)
+
+ Three more SQLite files a declared datasource cannot use, each now classified by the file's own driver at connect:
+
+ - **A database whose header or schema SQLite cannot read** (a file truncated inside its 100-byte header, or short of the pages its schema names). The connection's first statement, `PRAGMA auto_vacuum`, answers `SQLITE_CORRUPT`; `SqlDriver.connect()` logged it as "Failed to set PRAGMA auto_vacuum=INCREMENTAL" and resolved over a file no statement could read. On a development boot, `resolveSqliteDriver` (`@objectstack/service-datasource`) then stepped down to wasm SQLite, whose open renamed the file to `.corrupt-` and wrote an empty database in its place, under a warning that blamed the native addon. `connect()` now refuses it with a `SqliteDatabaseFileError` (`code: 'SQLITE_CORRUPT'`, `filename`, a message naming the file) in every mode, before anything is written, and `resolveSqliteDriver` re-throws it instead of stepping down. A database damaged deeper inside still connects: its first statement answers, and the damage surfaces on the read that reaches it, as before.
+ - **A dangling symbolic link as the database's directory, on the boot that writes.** `connect()` took `mkdir`'s `EEXIST` on the link for a concurrent creator and resolved; the first statement then failed with a code-less "directory does not exist", and a development boot stepped down to wasm SQLite under the same false native-addon warning. `connect()` now refuses it with `SqliteDatabaseFileError` (`code: 'ENOENT'`), naming the file and the link, which is the error the read-only boot (`sqliteAbsentFile: 'empty-in-memory'`) gives for the same path.
+ - **A dangling symbolic link as the database file itself, on the read-only boot.** When the directory its chain of links ends in does not exist, the read-only boot read the file absent and reported the datasource connected, while the boot that writes refuses the same path with `SQLITE_CANTOPEN`. The read-only boot now refuses it too (`code: 'ENOENT'`), naming the link's target. When that directory exists, both boots behave as before: the read-only boot opens an empty in-memory database, and the boot that writes creates the file through the link.
+
+ A real database file, an absent file in a directory that exists or can be made, and a native addon that cannot load over a real database keep today's behaviour.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (`SqlDriver.connect()`'s accept set and the read-only boot's narrow): a connect that resolved over a SQLite database whose header SQLite cannot read, or over a path whose directory is a dangling symbolic link, now rejects; a read-only boot that read a dangling file link absent although its target's directory does not exist now rejects. It ships as `minor` under the launch-window convention for accept-set narrowings: during the launch window a breaking change does not take `major`, so the level does not say it breaks, and breaking-ness is carried by this banner and the disposition above.
+
+ - **Before:** `connect()` resolved; in production the first query failed with `DATABASE_ERROR`, and on a development boot a damaged file was renamed to `.corrupt-` and replaced with an empty database, and a dangling-link directory stepped down to wasm SQLite under a warning that blamed the native addon. The read-only boot reported a dangling file link connected.
+ - **Now:** `connect()` rejects with `SqliteDatabaseFileError`, `code: 'SQLITE_CORRUPT'` for the damaged file and `code: 'ENOENT'` for the dangling-link directory, naming the file (and the link); the read-only boot rejects the dangling file link with `code: 'ENOENT'`, naming its target. The file is untouched.
+ - **Fix:** restore the database from a backup, or move the damaged file aside yourself (an absent file is created empty on the next connect); point the dangling link at a directory that exists, or point the datasource at a real path.
+
+### Patch Changes
+
+- ea08ce5: fix(service-datasource): a datasource whose database client is not installed (`pg`, `mysql2`, `better-sqlite3`) is refused with the install of that client, not with "Run `pnpm install && pnpm build`"
+
+ Clause-②: no
+
+ - **What was wrong.** When a boot-critical datasource could not connect because a module could not be resolved, the refusal always ended on the unbuilt-workspace remedy: "The driver package could not be LOADED at all … That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for a monorepo checkout whose driver build output is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client: `pg` and `mysql2` are optional peers of `@objectstack/driver-sql` (and `better-sqlite3` an optional dependency) that the project installs itself, so neither command adds them, and a reader who followed the advice was refused again with the same text.
+ - **Now.** When the module that could not be resolved is one of those three clients (knex reports it as `Cannot find module 'pg'`), the refusal ends on the install of that client instead: `npm install pg` (with the `pnpm add` and `yarn add` spellings beside it), and no build step. It names no environment variable.
+ - **Unchanged.** A driver package whose build output is missing, or a required `@objectstack/…` driver package that is not installed (any but the optional `@objectstack/driver-turso`, `@objectstack/driver-mongodb` and `@objectstack/driver-sqlite-wasm`), still ends on `pnpm install && pnpm build`. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change: only the closing sentence of the refusal is different.
+
+ What to do: install the client the refusal names next to the server that opens the datasource, then start again.
+- b811c73: fix(service-datasource): a datasource whose optional driver package is not installed (`@objectstack/driver-turso`, `@objectstack/driver-mongodb`, `@objectstack/driver-sqlite-wasm`) is refused with that package's install alone, no longer followed by "Run `pnpm install && pnpm build`"
+
+ Clause-②: no
+
+ - **What was wrong.** A `turso`, `mongodb` or `sqlite-wasm` datasource whose driver package is not installed is refused at boot, and the refusal already says so and names the install (`npm install @objectstack/driver-turso`, with the `pnpm add` and `yarn add` spellings beside it). It then went on to end on the unbuilt-workspace remedy anyway: "… That is a build precondition … Run `pnpm install && pnpm build`, then start again." Those are two fixes that contradict each other, and the second does nothing for a project, which adds these optional packages itself. Measured on the published release with a declared `turso` datasource and no `@objectstack/driver-turso` installed.
+ - **Now.** When the module that could not be resolved is one of those three packages, by its bare name (`Cannot find package '@objectstack/driver-turso'` under ESM, `Cannot find module '@objectstack/driver-turso'` under CJS), the refusal ends on one sentence that defers to the install named above it. It names no second command, no build step and no environment variable.
+ - **Unchanged.** A driver package whose build output is missing (the error names a path into its `dist/`, which is what a monorepo checkout that has been installed but not built reports), or a required `@objectstack/…` package that is not installed, still ends on `pnpm install && pnpm build`. A missing `pg`, `mysql2` or `better-sqlite3` client still ends on the install of that client. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change, and the driver factory's own messages are unchanged: only the closing sentence of the refusal is different.
+
+ What to do: install the driver package the refusal names next to the server that opens the datasource, then start again.
+- 5d27d32: fix(service-datasource): in development, a SQLite datasource whose file cannot be opened or created fails its connect, as in production, instead of stepping down to an in-memory engine
+
+ Clause-②: no
+
+ `resolveSqliteDriver` probes a native `better-sqlite3` connection in development and steps down to wasm SQLite, then to an in-memory engine, when the probe fails. It used to step down on any probe failure. So a declared SQLite datasource whose file could not be opened read `connected` on a development boot (`NODE_ENV=development`, which the source-tree `bin/run-dev.js` sets). Examples: a path under a regular file, a parent directory the process cannot make, a permission refusal, a directory where the file should be. Every query on it then went to an empty in-memory store, so a maintenance command could report a store it never opened as examined and clean.
+
+ - **Now:** a probe failure that is the database file failing to open or be created is re-thrown in development, as in production. The datasource's connect fails with the driver's `SqliteDatabaseFileError`, which names the file and the operating system's answer (`EEXIST`, `ENOTDIR`, `ENOENT`, `EACCES`, … or `SQLITE_CANTOPEN`). `listConnectionStates()` reports it as `failed`. This holds for every SQLite datasource, the primary included, so a development boot whose primary database file cannot be opened now stops and names the file.
+ - **Unchanged:** a native addon that cannot load (an ABI mismatch, an addon that was never built, a missing `better-sqlite3`) still steps down to wasm SQLite, with the same warning. An absent file in a directory that exists, or that the driver can make, still opens natively and is created. Production behaviour is unchanged.
+
+ What to do: if a development boot now refuses a SQLite datasource, fix its `filename`. The boot names the path it could not open.
+- 28c9a2c: fix(spec,driver-turso)!: a turso datasource that declares `encryptionKey` on a local file or an embedded replica is refused where it is written and when the driver is built, instead of being accepted and written in plaintext
+
+ Clause-②: no (narrowing) — the accept set of the `turso` `datasource.config` contract, of the published `TursoConfigSchema` mirror and of `new TursoDriver()` narrows by one key beside a `file:` url or `:memory:`, in every mode. No key is added, removed or renamed, and no exported symbol moves. `@objectstack/service-datasource` is graded `patch`: the TSDoc of its exported `TursoDriverConfigInput.encryptionKey` no longer promises an encrypted file, and nothing else in it changes.
+
+ `encryptionKey` was declared as an "AES-256 encryption key for the local database file", and nothing applied it. The turso driver reads and writes a local or replica file through better-sqlite3, which has no encryption codec (`PRAGMA key` is a silent no-op there). Measured on the driver source with a test key: a local datasource with `encryptionKey` constructed, connected and wrote its rows, and plain better-sqlite3, opening the file read-only with no key, read them back. An embedded replica fared no better: only its sync client was handed the key, after the local engine had already written the file in plaintext, and a file that client encrypted is one the local engine cannot open. An operator who declared the key got an unencrypted file and no signal.
+
+ **BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). A non-empty `encryptionKey` on a local datasource (a `file:` url or `:memory:`) or an embedded replica (a `file:` url beside `syncUrl`), detected or forced with `mode`, is refused now, at both doors together, with one message per mode that names the mode and what does encrypt. The key is judged by where the database lives, not by the mode: the same `file:` url or `:memory:` under a forced `mode: 'remote'` is refused too, in its own words, because the driver hands that url to the libSQL client it builds, which is handed no key, so the file was written in plaintext all the same:
+
+ - **at authoring**, as one `custom` issue on `encryptionKey` (`config.encryptionKey` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
+ - **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
+
+ For a local or replica datasource the message is the same text at all three places; for a `file:` url or `:memory:` under a forced `mode: 'remote'`, the schema and the constructor give the forced-remote text, while the driver mirror, which declares no `mode` and strips it, refuses the same config in the local text. A test holds the constructor's copy equal to the schema's issue byte for byte in every mode, and the mirror's verdict and words equal to the schema's wherever no `mode` is forced. It comes last among the local-engine refusals: a url the engine cannot open, a lone `sync` and a forced `mode` the driver would ignore each keep their own message. An empty `encryptionKey` is unset and is still accepted. A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file.
+
+ ### Migration: FROM → TO
+
+ | You wrote | Write instead |
+ | --- | --- |
+ | `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
+ | `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way |
+ | `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way |
+ | either, where the data must be encrypted at rest and no encrypted volume is available | a remote Turso database (`url: 'libsql://my-db.turso.io'`), whose storage encryption is the server's to configure, not this key's |
+
+ The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
+
+ A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext.
+
+ Blast radius, measured on this tree: no example, template, CLI scaffold or published skill authors the key, and no host default or environment variable sets it (a turso `encryptionKey` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.
+
+
+- 03966ce: fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
+
+ `@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
+
+ Clause-②: no (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
+
+ - **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
+ **TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
+ - **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
+ **TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
+ - **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
+ **TO:** it answers `400` with `error: 'invalid_grant'`.
+ - **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
+ **TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
+ - **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
+ **TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
+ - **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
+ **TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
+ - **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
+ **TO:** the same `401` also clears the stale session cookies.
+ - **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
+ - **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
+ - **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
+ - **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
+ - **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [74fcec1]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [eee65af]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [7f99616]
+- Updated dependencies [4d99158]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4e2c42f]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/driver-sql@18.1.0
+ - @objectstack/driver-turso@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/driver-memory@18.1.0
+ - @objectstack/driver-mongodb@18.1.0
+ - @objectstack/driver-sqlite-wasm@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json
index 22e90230026..14c47df5742 100644
--- a/packages/services/service-datasource/package.json
+++ b/packages/services/service-datasource/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-datasource",
- "version": "18.0.0",
+ "version": "18.1.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 0ae277f2a57..4657cd6e7ba 100644
--- a/packages/services/service-i18n/CHANGELOG.md
+++ b/packages/services/service-i18n/CHANGELOG.md
@@ -1,5 +1,41 @@
# @objectstack/service-i18n
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json
index dccf9862121..0203980f1f1 100644
--- a/packages/services/service-i18n/package.json
+++ b/packages/services/service-i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-i18n",
- "version": "18.0.0",
+ "version": "18.1.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 3b26cd27e63..81ab7a8d7ff 100644
--- a/packages/services/service-job/CHANGELOG.md
+++ b/packages/services/service-job/CHANGELOG.md
@@ -1,5 +1,52 @@
# @objectstack/service-job
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json
index e136c58251d..e5dee734ca1 100644
--- a/packages/services/service-job/package.json
+++ b/packages/services/service-job/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-job",
- "version": "18.0.0",
+ "version": "18.1.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 e7929d5212e..30a39351cba 100644
--- a/packages/services/service-knowledge/CHANGELOG.md
+++ b/packages/services/service-knowledge/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/service-knowledge
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json
index 893bbc0c35c..2d8b9bbd736 100644
--- a/packages/services/service-knowledge/package.json
+++ b/packages/services/service-knowledge/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-knowledge",
- "version": "18.0.0",
+ "version": "18.1.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
index 94f07f6ca28..22be0cc3b8e 100644
--- a/packages/services/service-mailbox/CHANGELOG.md
+++ b/packages/services/service-mailbox/CHANGELOG.md
@@ -1,5 +1,54 @@
# @objectstack/service-mailbox
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/types@18.1.0
+ - @objectstack/connector-imap@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-mailbox/package.json b/packages/services/service-mailbox/package.json
index 9f33db1b60c..0ea1ad87852 100644
--- a/packages/services/service-mailbox/package.json
+++ b/packages/services/service-mailbox/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-mailbox",
- "version": "18.0.0",
+ "version": "18.1.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 51eca3f52ec..7f9d7123631 100644
--- a/packages/services/service-messaging/CHANGELOG.md
+++ b/packages/services/service-messaging/CHANGELOG.md
@@ -1,5 +1,146 @@
# @objectstack/service-messaging
+## 18.1.0
+
+### Minor Changes
+
+- 865d2c8: fix(service-messaging): a notification template is resolved in the recipient's organization
+
+ Clause-②: yes (widening) — `NotificationTemplateStore.load` (exported from the package's `.` entry) gains an optional fourth parameter, `organizationId`.
+
+ `sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The email and SMS channels now render the template row of the delivery's own organization: `NotificationTemplateStore.load(topic, channel, locale, organizationId)` keys its read by the organization the delivery names (`Notification.organizationId`, the organization the notification event row carries), the same way the preference resolver scopes its rows.
+
+ - **An organization with no row of its own** for a `(topic, channel, locale)` renders the notification's own title and body, the documented no-template fallback.
+ - **The new parameter:** `NotificationTemplateStore.load(topic, channel, locale, organizationId?)`. A call that names no organization reads no row and answers `null`, so the channel renders that same fallback. Every delivery the messaging service fans out names its organization (the event row takes it from the producer or from the engine's system-write rule, and an emit with neither is refused), so this changes nothing a composed deployment renders. A direct caller of `NotificationTemplateStore.load` passes the recipient's organization as the fourth argument.
+ - **Unchanged:** the locale ladder (the recipient's locale, its language, then `en`), the `is_active` filter, how a row renders, and the best-effort answer on a failed lookup.
+- 63085c3: feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing.
+
+ ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match.
+
+ **What moves for consumers, FROM → TO.**
+
+ - **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it.
+ - **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles.
+ - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged.
+ - **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.
+
+ **Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`.
+- 58cd58f: feat(service-messaging): a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language
+
+ Clause-②: yes (widening): `EmitInput` gains an optional `keyedText` member, and the package's `.` entry gains `NotificationTextRenderer`, `assertNotificationTextKeys` and their types. An emit without `keyedText` is accepted and delivered exactly as before.
+
+ - **What was wrong.** `emit()` took the text a recipient reads only as `payload.title` and `payload.body`, literals the producer builds before any recipient exists. So every recipient of a notification read the producer's language. The approvals plugin's notifications reached zh-CN, ja-JP and es-ES approvers in English.
+ - **The new member.** `EmitInput.keyedText` is `{ titleKey?, bodyKey?, params? }`. `titleKey` and `bodyKey` are `II18nService` keys made of a face and an id, such as `messages.approvalRequestedTitle`. A producer contributes their entries through its own translation bundle (`i18n.loadTranslations`, ADR-0029 D8). `params` fills the entries' `{{name}}` holes. A param is a string, or `{ key, fallback }`: the recipient-language text of `key` (such as `objects..label`), else `fallback`.
+ - **Where it renders.** At delivery, per recipient, before the channel is called: on the inline fan-out, on each row the dispatcher sends, and on each row of a digest group. The language is the recipient's own `sys_user.locale`, else the deployment default (the recipient-locale chain, `resolveRecipientLocale`), negotiated against the locales the `i18n` service holds. Every channel, a third party's included, receives the rendered `title` and `body` it already reads.
+ - **The fallback.** The literal `payload.title` and `payload.body` stay what a recipient reads whenever a key cannot be rendered: no `i18n` service, no renderer composed, or a key with no entry in the recipient's locale or in the catalog's declared fallback locale. A member the producer left unkeyed is also delivered as the literal. A key with no entry is said once per key and locale at `warn`.
+ - **Refused at emit.** A malformed `keyedText` throws a `TypeError` before anything is written: an undeclared member, a key without a face, a param name no `{{hole}}` can spell, or a keyed param without its `fallback`.
+ - **The delivery snapshot reserves `keyedText`,** the way it already reserves `title`, `body`, `severity`, `actionUrl` and `actorId`. The snapshot stores `EmitInput.keyedText` under that name only when the emit keyed its text. A producer's own payload member of that name (a flow `notify` node's free-form payload, say) is dropped from the snapshot and is never rendered. No producer in this repository writes one.
+ - **Cost.** One `sys_user` read per keyed delivery (one per digest group). A delivery without keys, or a deployment with no `i18n` service, makes no read.
+ - **Unchanged.** Every emit that passes no `keyedText` (and whose payload has no member of that name): the event row, the snapshot, and what the inbox, email and SMS channels deliver. The notify `template` path, `sys_notification_template` rendering (which now falls back to the rendered text), recipients, preferences and channel availability are also unchanged.
+ - **Composing it.** `MessagingServicePlugin` wires one `NotificationTextRenderer` into the service and the dispatcher. A hand-built `MessagingService` passes `textRenderer` in its context and a `NotificationDispatcher` passes it in its options. Without one, keyed text is delivered as its literal, and the service says so once.
+- d68aab6: fix(service-messaging)!: `sys_notification_template` refuses a write that leaves a row naming a channel other than `email` or `sms`, because no channel ever sends such a row
+
+ A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row for `inbox`, `push` or any other value was stored and then rendered nowhere; the `channel_renders_template` rule only warned about it. That rule now refuses. It is an invariant judged over the stored row overlaid with the write, so it answers rows already stored with another channel as well as new ones: such a row is refused on any edit that leaves its channel out of the set, until an edit moves it to `email` or `sms`, and it is still read, listed and deleted. No stored row is rewritten or deleted by this change.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (a platform object's write accept set narrows, stored rows included), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row naming a channel other than `email` or `sms` was accepted and stored, with at most a `channel_renders_template` advisory in the write answer's `warnings`. A row already stored with such a channel took every edit.
+ - **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding on `channel` (`code: 'rule_violation'`); an import commit fails that row with `field: 'channel'` and `code: 'rule_violation'`; a bulk update fails that row with `code: 'VALIDATION_FAILED'` and `httpStatus: 400`, and a predicate update over a set holding such a row writes no row. Every refusal carries the prescription: set the channel to `email` or `sms`, or delete the template; in-app content is an email template (`email_template`) named in a notify step's `template`.
+ - **Unchanged:** `email` and `sms` rows and every write to them; reading, listing and deleting any row; how every channel renders, so a row for another channel is still sent by none; and the `channel` column and its width.
+ - **The fix for an operator:** a row for another channel was never sent. Delete it, or set its channel to `email` or `sms` if its text belongs there. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it.
+- a39389e: fix(service-messaging)!: an html or mjml `sys_notification_template` body escapes every hole's value for markup
+
+ The email channel renders a notification template row whose `format` is `html` or `mjml` into the mail's html part. Its `{{ payload.x }}` holes placed each value into that markup as stored, so a text field carrying markup characters — a person's display name, a record title, a comment — was read as markup by the recipient's mail client. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike, and the template's own markup renders as written. Stored text is never stripped or refused: a display name may contain any character, and the mail client shows it as typed.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (what an html or mjml notification template renders for a markup-bearing value narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** in an html or mjml `sys_notification_template` body, a `{{ payload.x }}` hole rendered its value verbatim, so a value carrying markup became markup in the sent html part.
+ - **TO:** the same hole renders the value HTML-escaped: an ampersand, an angle bracket or a quote arrives as the matching HTML entity, which a mail client displays as the original character.
+ - **Unchanged:** the subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. A value without markup characters renders byte-identically. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged, and no stored row is rewritten.
+ - **What to do:** a template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- 0603bfa: fix(service-messaging): the `sys_notification_template` copy now says that only the email and SMS channels use a template row, and that the in-app inbox never does
+
+ A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so.
+
+ This entry first added a `channel_renders_template` warning for such a write; in the same release #24983's refusal superseded it, so a write naming another channel is refused rather than warned about, as that entry describes. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does.
+
+ Clause-②: no — this entry now describes copy only (the object's description, its empty state and the field help); the narrowing of what a write may send is #24983's, declared in its own entry.
+
+ - **Unchanged.** Nothing this entry describes changes which writes are accepted. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged.
+ - **What to do.** For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. A row for another channel was never sent, so delete it if you no longer want it listed.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json
index 930a94b5314..df26408cb47 100644
--- a/packages/services/service-messaging/package.json
+++ b/packages/services/service-messaging/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-messaging",
- "version": "18.0.0",
+ "version": "18.1.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 c0c68886880..30c429d1397 100644
--- a/packages/services/service-package/CHANGELOG.md
+++ b/packages/services/service-package/CHANGELOG.md
@@ -1,5 +1,42 @@
# @objectstack/service-package
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json
index ab9decbc4fa..9e56ced8d5c 100644
--- a/packages/services/service-package/package.json
+++ b/packages/services/service-package/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-package",
- "version": "18.0.0",
+ "version": "18.1.0",
"license": "Apache-2.0",
"description": "Package management service for ObjectStack — publish, install, and manage packages",
"type": "module",
diff --git a/packages/services/service-print/CHANGELOG.md b/packages/services/service-print/CHANGELOG.md
index c589ea6eb5f..f40c67cde30 100644
--- a/packages/services/service-print/CHANGELOG.md
+++ b/packages/services/service-print/CHANGELOG.md
@@ -1,5 +1,42 @@
# @objectstack/service-print
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/service-storage@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json
index 618c7c2cbd0..bdaf5429b1d 100644
--- a/packages/services/service-print/package.json
+++ b/packages/services/service-print/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-print",
- "version": "18.0.0",
+ "version": "18.1.0",
"license": "Apache-2.0",
"description": "Print Service for ObjectStack — renders a record's print page in headless Chromium under the requesting user's own principal and archives the PDF as a file attached to the record.",
"type": "module",
diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md
index 7c519c57857..f3c22a9c901 100644
--- a/packages/services/service-queue/CHANGELOG.md
+++ b/packages/services/service-queue/CHANGELOG.md
@@ -1,5 +1,52 @@
# @objectstack/service-queue
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json
index 7727b672a8d..863880d1a12 100644
--- a/packages/services/service-queue/package.json
+++ b/packages/services/service-queue/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-queue",
- "version": "18.0.0",
+ "version": "18.1.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 e35acc9d568..e0ec45d04f8 100644
--- a/packages/services/service-realtime/CHANGELOG.md
+++ b/packages/services/service-realtime/CHANGELOG.md
@@ -1,5 +1,52 @@
# @objectstack/service-realtime
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json
index bec885d1012..58dde9cb981 100644
--- a/packages/services/service-realtime/package.json
+++ b/packages/services/service-realtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-realtime",
- "version": "18.0.0",
+ "version": "18.1.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 bd093d0ada3..04be4406f56 100644
--- a/packages/services/service-settings/CHANGELOG.md
+++ b/packages/services/service-settings/CHANGELOG.md
@@ -1,5 +1,125 @@
# @objectstack/service-settings
+## 18.1.0
+
+### Minor Changes
+
+- 78ab647: fix(service-settings)!: the Branding settings drive the console's name, logo and colour on a single-organization deployment, retire `theme_mode` and `support_email`, and are not offered under a walled tenancy posture
+
+ Clause-②: no (narrowing)
+
+
+
+ Setup → Branding saved values that nothing in the console ever read. Under the maintainer's ruling, its three keys now drive the console's own branding through the runtime config (`@objectstack/cloud-connection`, same release), the two keys with no counterpart retire, and the page is offered only where no organization wall stands.
+
+ **What the three keys do now** (a single-organization deployment, the `single` posture):
+
+ - `workspace_name` is the product name in the console, on the sign-in page and in the browser title, and it stays the product name in sign-in emails, the authenticator app and MCP sign-in.
+ - `logo_url` is the logo on the sign-in page, the loading screen and the home header.
+ - `accent_color` is the browser and installed-app theme colour. Each app keeps the colours its own `AppBranding` declares.
+ - A key nobody set falls back to the deployment's own branding (`OS_PRODUCT_NAME`, `OS_LOGO_URL`, `OS_BRAND_COLOR`, or the host's `RuntimeConfigPlugin` options). A value pinned with the settings layer's `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` or `OS_BRANDING_ACCENT_COLOR` counts as set. A save shows on the next page load, with no restart. `accent_color` no longer declares the `#6366f1` default, which the page showed as a colour the console never used.
+
+ **BREAKING: `theme_mode` and `support_email` are retired.** The Branding manifest (`brandingSettingsManifest`) drops both specifiers and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles, and its `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed. Nothing ever read either key. A value a workspace already stored is kept:
+
+ - The `sys_setting` row stays exactly as it was. No read or save rewrites or deletes it, and it never blocks saving the live keys.
+ - `GET /api/settings/branding` does not resolve it: the key is absent from both `manifest.specifiers` and `values`.
+ - A `PUT /api/settings/branding` that names `theme_mode` or `support_email` is refused with `400 UNKNOWN_KEY` (`details.key` names it). The refusal covers the whole batch, so a live key sent beside it does not land either.
+ - `OS_BRANDING_THEME_MODE` and `OS_BRANDING_SUPPORT_EMAIL` are no longer read on any deployment.
+
+ **BREAKING: under the `group` and `isolated` postures the Branding namespace is not offered.** It is absent from `GET /api/settings`, `GET` or `PUT /api/settings/branding` is refused with `404 UNKNOWN_NAMESPACE`, the answer every unknown namespace gets, and Setup lists no Branding entry: `SettingsServicePlugin` now contributes the `nav_settings_branding` Setup entry itself, with the namespace, so the entry exists exactly where the page does. The runtime config is an anonymous door that resolves no organization before sign-in, so on a walled deployment the branding is the operator's: the console's through `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`, and the auth product name through `OS_APP_NAME`.
+
+ **On a walled deployment the `OS_BRANDING_*` variables are no longer read.** With the namespace withheld, none of `OS_BRANDING_WORKSPACE_NAME`, `OS_BRANDING_LOGO_URL` and `OS_BRANDING_ACCENT_COLOR` reaches a reader there. One of them had an effect before this release: `OS_BRANDING_WORKSPACE_NAME` named sign-in emails, the authenticator issuer and the MCP sign-in (`@objectstack/plugin-auth` applied it as an `env` value whatever the posture). So did a `branding.workspace_name` row stored with no organization. Both now give way to the `OS_APP_NAME` chain. The fix is in the deployment's environment, not in any code or metadata: a walled deployment that set `OS_BRANDING_WORKSPACE_NAME=Acme` sets `OS_APP_NAME=Acme` instead. The other two variables had no reader before and have none on a walled deployment now. A value already stored is kept and is offered again if the deployment returns to `single`. A host that passes its own `branding` manifest through `SettingsServicePlugin({ manifests })` gets the same rule.
+
+ **What to do after upgrading.**
+
+ - On a single-organization deployment nothing has to be rewritten.
+ - There is no replacement for `theme_mode` (the console follows the user's own theme choice) or for `support_email`.
+ - On a walled deployment, set the console's branding with `OS_PRODUCT_NAME`, `OS_LOGO_URL` and `OS_BRAND_COLOR`. If you set `OS_BRANDING_WORKSPACE_NAME` there, move its value to `OS_APP_NAME`.
+
+ It ships as `minor` under the launch-window convention for narrowings.
+- 3b3830c: fix(service-settings)!: a settings value that misses the format its `email`, `url`, `phone` or `color` type declares is refused, both when it is saved and when it arrives as an `OS_*` override (#24882)
+
+ ADR-0007 declares a format for four specifier types: `email`, `url` and `phone` take "format validation", and `color` takes a hex string. Until now nothing enforced it. `PUT /api/settings/branding` stored a Logo URL of `not a url` and answered `200`, and an `OS_*` override was served whatever it held. Both doors now judge the format through one shared check, after every other declared constraint, and refuse a value that misses it.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (the settings doors' accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ The formats are the platform's existing grammars for the same words:
+
+ - `email`: an address with a dotted domain, the grammar the mail transport sends with. `no-reply@localhost` misses it.
+ - `url`: `scheme://…`, a root-relative `/path`, a `data:` URI or a `blob:` URL. A relative logo path that the branding reader serves is accepted.
+ - `phone`: digits, spaces and `+ ( ) - .`, at least five characters.
+ - `color`: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
+
+ Empty values and non-string values are not judged. Surrounding whitespace is ignored, because the readers trim it before use.
+
+ What changes:
+
+ - **Saving, FROM:** a save naming a value that misses its key's format answered `200` and stored the value as written.
+ - **Saving, TO:** the save answers `400` with `error.code: 'SETTINGS_VALIDATION'` and one entry per such key in `error.details.fields`, carrying `code` `invalid_email`, `invalid_url`, `invalid_phone` or `invalid_format` (for `color`), and `constraint.type`. Nothing in the save is stored. The answer names the expected format and never echoes the value.
+ - **Environment override, FROM:** a malformed `OS_*` override was in force: reads served it with source `env`, and it locked the key against saves.
+ - **Environment override, TO:** a malformed `OS_*` override is no longer in force. The server logs one `error` line per variable and value, naming the variable, the setting, the type and the expected format, never the value. Reads serve the next layer instead: the stored value when one exists, otherwise the manifest default, with that layer as the source. The key is no longer locked, so it can be saved through Setup.
+ - **Unchanged:** a well-formed value saves, and a well-formed override is in force and locks the key. A value that breaks another declared constraint (a length bound, a pattern) is still refused for that constraint. A value stored before this release is still served by every read, and it does not block a save of the namespace's other keys; only a save that writes that key, or a key its `visible` condition reads, judges it. An all-null save (reset) still clears it.
+ - **What to do:** fix the value. For an `OS_*` override, set the variable to a well-formed value or unset it and configure the setting in Setup. For a stored value, save a well-formed value for the key, or clear it.
+
+### Patch Changes
+
+- fcb40ad: fix(service-settings): Setup → Authentication saves "Enable Google login" ON when the Google credentials come from the server (#24844)
+
+ Clause-②: no
+
+ On a deployment that sets `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and leaves the two credential fields empty, saving `auth.google_enabled: true` was refused with `SETTINGS_VALIDATION` (`google_client_id` / `google_client_secret`: `required`). An admin who switched Google off on the page could not switch it back on there, although the server held valid credentials. The settings service reads only `OS_*` names, so it cannot see that pair.
+
+ `auth.google_client_id` and `auth.google_client_secret` are no longer `required`. Google sign-in needs the two credentials to resolve, saved on the page or set on the server; it never needed them stored. The `google_enabled` help already says so: Google is offered once both resolve, and the server's pair wins over saved values.
+
+ Who may sign in, and which providers the login page offers, do not change for any deployment. Saving the toggle ON while neither pair resolves is now accepted; the login page then shows no Google button, as it does with the toggle left at its default.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json
index 1a86b03b400..fc420fbc388 100644
--- a/packages/services/service-settings/package.json
+++ b/packages/services/service-settings/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-settings",
- "version": "18.0.0",
+ "version": "18.1.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 c0dc6558f72..c666ac9f9c6 100644
--- a/packages/services/service-sms/CHANGELOG.md
+++ b/packages/services/service-sms/CHANGELOG.md
@@ -1,5 +1,69 @@
# @objectstack/service-sms
+## 18.1.0
+
+### Patch Changes
+
+- abd5acd: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-email, service-sms): Setup's "Send test email" and "Send test SMS" send to the signed-in admin when the request names no recipient (#24767)
+
+ Clause-②: no
+
+ The Mail and SMS settings pages offer a Send test button and no recipient input, so the console's request carries no `to`. "Send test email" fell back to the From address — often a no-reply mailbox nobody reads — and with From empty it refused, asking for a "to" address the page has no field for. "Send test SMS" refused every press the same way.
+
+ With no `to` in the request, the test now goes to the person who pressed the button: `mail/test` sends to the caller's own account email and `sms/test` to the caller's own phone number, each read from the caller's own `sys_user` row. A `to` in the request body still wins, as before. When the caller's account has no address or number, or the account cannot be read, the action refuses and says that nothing was sent. A phone number is on an account only where phone sign-in is enabled, so on other deployments "Send test SMS" still needs a `to` in the request body.
+
+ `sms/test` no longer reports a send through the log-only transport (the namespace's default `None (log only)` provider) as a success. It answers `ok: false` with severity `warning`, as `mail/test` already does for its log transport. When the form names no delivering provider and the transport the deployment configured at boot carries the test, the success message names that transport instead of saying the message went "via log".
+
+ **Unchanged.**
+
+ - The request and result shapes of `POST /api/settings/mail/test` and `POST /api/settings/sms/test`: no key, route or setting is added.
+ - Which provider and credentials a test uses: the saved values overlaid with the form's unsaved edits, as before.
+ - Saving the Mail form still judges a required field only when the save writes it, or writes a key its visibility depends on.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [c2d4072]
+- Updated dependencies [78ab647]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [55d7aab]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [688052e]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json
index 1b2abe8f2fc..2393034170f 100644
--- a/packages/services/service-sms/package.json
+++ b/packages/services/service-sms/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-sms",
- "version": "18.0.0",
+ "version": "18.1.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 a5e6cfdf4d9..efec25366b5 100644
--- a/packages/services/service-storage/CHANGELOG.md
+++ b/packages/services/service-storage/CHANGELOG.md
@@ -1,5 +1,66 @@
# @objectstack/service-storage
+## 18.1.0
+
+### Patch Changes
+
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [73a2b52]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/observability@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json
index 41417e21777..6e36e7e58ed 100644
--- a/packages/services/service-storage/package.json
+++ b/packages/services/service-storage/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/service-storage",
- "version": "18.0.0",
+ "version": "18.1.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
index 5de333b9d49..d85d8910d79 100644
--- a/packages/skills/CHANGELOG.md
+++ b/packages/skills/CHANGELOG.md
@@ -1,5 +1,18 @@
# @objectstack/skills
+## 18.1.0
+
+### Patch Changes
+
+- c0cde8b: docs(skills): the npm page and the catalog's front page give the install line for the project's own package manager
+
+ Clause-②: no
+
+ `README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) printed `npm install --save-dev "@objectstack/skills@$(node -p …)"` as the only install line. In a pnpm-installed project that line exits `1` with `npm error Cannot read properties of null (reading 'edgesOut')` and writes nothing, and `create-objectstack` installs with pnpm when it is available, so a scaffolded project is commonly a pnpm project.
+
+ - **Both pages** now print `pnpm add -D "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"`, with the npm and yarn spellings on a comment line beside it, and the rule: add the package with the manager that installed the project. The `objectstack-upgrade` skill's re-install step, which an agent follows for this exact move, prints the same line.
+ - **The npm page** names pnpm's `ERR_PNPM_NO_MATCHING_VERSION` beside npm's `ETARGET` for a project on `@objectstack/spec` 17.x, and runs `npx skills list` before `npx skills remove --all`, which removes every installed skill without asking.
+
## 18.0.0
### Minor Changes
diff --git a/packages/skills/package.json b/packages/skills/package.json
index 2e64272b8d8..33457472c87 100644
--- a/packages/skills/package.json
+++ b/packages/skills/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/skills",
- "version": "18.0.0",
+ "version": "18.1.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 7cc697e4b20..57442411f19 100644
--- a/packages/spec/CHANGELOG.md
+++ b/packages/spec/CHANGELOG.md
@@ -1,5 +1,366 @@
# @objectstack/spec
+## 18.1.0
+
+### Minor Changes
+
+- 7cacad5: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
+
+ Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
+
+
+
+ **BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
+
+ - **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
+ - **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
+ - **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
+ - **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
+ - **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
+ - **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
+
+ Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
+- 7532ea3: feat(objectql): a caller value that a stored field's `expression` replaced is reported through `onFieldsDropped` / `droppedFields` under the new `DroppedFieldsEvent.reason` value `stored_expression`
+
+ Clause-②: yes (widening)
+
+ The engine computes a stored (non-`formula`) field's declared `expression` on every write, so a value the caller sends for such a field never stands. Until now nothing said so. `onFieldsDropped` stayed silent, so the PATCH and `/batch` doors answered with no `droppedFields`, and a flow's `update_record` reported success over a row that holds the computed value instead of the one sent.
+
+ - **New `reason` value.** `DroppedFieldsEventSchema.reason` (`@objectstack/spec/data`) gains `stored_expression`. It means the field is a stored column the engine computes from its `expression`, and the caller's value was replaced by the computed one. The field IS written, so `computed` (a `formula` with no column, whose key is dropped) would be wrong for it. The four existing values are unchanged.
+ - **When it is reported.** On insert (single, batch, and `insertMany`'s per-row `droppedFields`), on update by id, and on a predicate update, in every context, `isSystem` included. It is reported only when the caller sent the field and the value stored differs from the caller's value. A client that sends the computed value (the console's line-item grid) or no value is told nothing. A value another strip already took, such as a stored expression field that is also `readonly`, is reported once, under that strip's reason.
+ - **`strictReadonlyWrites` is unchanged.** The maintainer's ruling 「甲」 says only the loud half ships in 18.x. A strict write whose value for such a field differs from the computed one is NOT refused: it completes with the computed value, and the replacement is reported through `onFieldsDropped` under strict as well. A refusal is runtime behaviour that no conversion can carry, so the strict refusal of a `stored_expression` replacement is deferred to 19.0. A strict write that also carries a refusable drop (`readonly`, `readonly_when`, `primary_key`, `computed`) is refused as before, for those drops only, and fires no listener.
+ - **Flow step warning.** `@objectstack/service-automation` words the new reason. Its warning says the requested value(s) were not stored, rather than that the field was not written, and its closing sentence says the write succeeded with the computed value(s).
+
+ **For consumers that branch on `reason`.** A `Record` table fails its typecheck until it has a `stored_expression` row. That is the intended signal: add the row. A client running behind the server should treat a reason it does not recognise as "not applied", never as one of the known reasons.
+- fde7456: feat(spec)!: a list's `addRecord` block says what renders — `enabled` is required, `position` defaults to `top`, and `mode` / `formView` are retired
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes, and in 18.x under the standing rule for breaking narrowings: an ADR-0087 conversion carries every body that parsed under 18.0, so no stored row or built artifact is refused.
+
+ `AddRecordConfigSchema` — the `addRecord` block on a list view, an object's or a view container's `listViews`, a list page's `interfaceConfig` and a page `list-view` node — declared defaults the renderer never applied (`enabled` `true`, `position` `bottom`, `mode` `inline`) and two keys no renderer read. The list's Add button is drawn only for an explicit `enabled: true`, an absent `position` renders at the top, and every Add opens the object's create form. The block now says exactly that:
+
+ - **`enabled` is REQUIRED, with no default.** `addRecord: {}` is refused at parse (`os validate`, `os build`, the save doors) with a prescription naming the key, instead of drawing nothing.
+ - **`position` defaults to `top`**, where the renderer has always placed an absent position. Values stay `top` / `bottom` / `both`. No screen moves.
+ - **`mode` and `formView` are `retiredKey()` tombstones** (ADR-0049 enforce-or-remove): authoring either is a `tsc` error and a parse error carrying the prescription. The create form is chosen by convention instead: the create entry opens `formViews.create` when the object's view container declares it, else `form` (or `formViews.default`). How that form opens stays the renderer's.
+
+ ### FROM → TO
+
+ | before | after |
+ | --- | --- |
+ | `addRecord: {}` (parsed as `enabled: true`, drew nothing) | refused: write `addRecord: { enabled: true }` |
+ | `addRecord: { position: 'bottom' }` | refused: write `addRecord: { enabled: true, position: 'bottom' }` |
+ | `addRecord: { enabled: true }` (parsed `position: 'bottom'`) | parses as `position: 'top'`, where it always rendered |
+ | `addRecord: { enabled: true, mode: 'form' }` (any `mode`) | `addRecord: { enabled: true }` |
+ | `addRecord: { enabled: true, formView: 'create' }` | `addRecord: { enabled: true }`, with the form declared as `formViews.create` on the object's view container |
+
+ **The one-line fix:** delete `mode` and `formView`, and write `enabled: true` (or `enabled: false`) on every `addRecord` block. `os migrate meta --from 17` lists the edits for existing metadata sources; `--write` applies the ones it can prove, and you apply the rest by hand.
+
+ ### The ADR-0087 disposition
+
+ - D2 `add-record-mode-form-view-removed` strips `mode` and `formView` from every `addRecord` block it reaches (`stack.views[]` in all three persisted spellings, `objects[].listViews.*`, `pages[].interfaceConfig` and every page `list-view` node), with a notice per key. Lossless in pixels: no renderer read either key.
+ - D2 `add-record-enabled-defaulted` writes `enabled: true`, the default the 18.0 spec declared, onto a block that omits `enabled`, with a notice. Measured before choosing it: no producer in this repository (the showcase's two blocks, the lint fixture) or in objectui's Studio design surface writes `addRecord` without `enabled`, so the conversion carries the narrowing with a zero measured population. **One visible consequence, stated:** a stored block that omitted `enabled` drew no Add button, because the renderer read the raw body; converted, it draws the button its declaration always promised. To keep a list without one, write `enabled: false` or drop the block. An explicit `enabled` value is never touched.
+ - Both are retired from the load path: a live author is refused at parse and taught; stored rows, built artifacts inside their declared-floor window and `os migrate meta --from 17` replay them.
+ - `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/AddRecordConfig:mode` and `ui/AddRecordConfig:formView`; the D3 entries are `add-record-mode-form-view-retired` and `add-record-enabled-required`; the default moves (`enabled` `true` → none, `position` `bottom` → `top`) are declared in `DEFAULT_CHANGES_BY_MAJOR[18]`.
+
+ ### Also in this change
+
+ - The Studio view form and page form offer exactly `enabled` and `position` for `addRecord`.
+ - `@objectstack/lint`: `os explain list-view-field-unknown` no longer names `addRecord.formView` as a position another rule checks.
+ - The showcase's two `addRecord` blocks and the docs (`ui/create-vs-edit-form`, `ui/views`) drop `mode` / `formView` and state the create-form convention.
+- de9cd1f: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`
+
+ Clause-②: yes (widening)
+
+ - **Before.** An invitation naming a role outside the closed membership vocabulary was refused `400` with only a message (`ROLE_NOT_FOUND: `) and no `code`, so a client that branches on `error.code` (ADR-0112) could not recognise it. `update-member-role` already answered the same refusal with `code: 'ROLE_NOT_FOUND'`.
+ - **Now.** invite-member answers `400` with `code: 'ROLE_NOT_FOUND'` and the same message, naming the role, the body `update-member-role` gives for the same condition. The refusal still happens before any invitation row is written. This is the answer the invitation schema's `role` docstring and the authentication docs already promise.
+ - **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
+ - **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.
+- b9a79ef: feat(spec): the `flows` translation face gains `approvals..label`, the step label of an `approval` node
+
+ Clause-②: yes (widening)
+
+ - **Before.** `TranslationDataSchema.flows.` carried `label`, `successMessage`, `errorMessage`, `screens.` (screen nodes) and `refusals.` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens..title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen.
+ - **Now.** `flows..approvals..label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens..title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals..label` instead of being told only that the node is not a screen.
+ - **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands.
+ - **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.
+- 63085c3: feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing.
+
+ ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match.
+
+ **What moves for consumers, FROM → TO.**
+
+ - **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it.
+ - **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles.
+ - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged.
+ - **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that.
+
+ **Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`.
+- 30d21f6: feat(spec): a `page:tabs` item label gains a translation key, `pages..components..items..label`
+
+ Clause-②: yes (widening)
+
+ - **Before.** `pages..components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
+ - **Now.** `pages..components..items..label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
+ - **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
+ - **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
+ - **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.
+- eddffc3: feat(spec): a form view's `title` is translated through `objects.._views..title`, and `GET /forms/:slug` serves it in the visitor's language
+
+ Clause-②: yes (widening)
+
+ - **Before.** A form view's `title` (`FormViewSchema.title`) had no translation key. The `_views.` entry named `title` as a rename onto `label`, `translateView` never read `config.title`, and `GET /forms/:slug` answered the top-level `label` from the untranslated view. A public form that declared a title served it in the authoring language to every visitor, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.
+ - **Now.** `objects.._views..title` is a declared key, under the same `_views` node and view key as the view's `label`. `translateView` overlays it onto a served form item's `config.title`, on the same locale chain and override rule as every other view string: only a form that authors a title is translated, a missing key keeps the authored title, and a title edited on top of the packaged view keeps the edit. `heading` and `formTitle` are named as renames onto `title`.
+ - **`GET /forms/:slug`.** The served form's `title` is the translated one, and the top-level `label` is taken from the translated view when the view authors a `label`. A view that authors none is still answered without one, so the console heading (`label`, then `form.title`, then the translated object label) is unchanged for it.
+ - **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts a `title` key over a form view (a `formViews.` entry, the default `form`, or a `viewKind: 'form'` item) and refuses it, as `translation-target-unknown`, over a list view, pointing at the view's `label` key.
+ - **`os i18n extract`.** The CLI extractor emits `_views..title` for each form a view container serves (keyed by the composer's runtime name) and for a `viewKind: 'form'` item, so the skeleton carries the slot and the i18n coverage gate reports an authored title a supported locale leaves untranslated.
+ - **Unchanged.** Every other `_views` key, the view `label` and `description` reads, and a form without a title.
+- e939357: spec!: `WebhookSchema` declares `secret` (a credential value) and `headers` (a credential map, as a whole) with `.meta({ credential: 'value' | 'map' })`, and `getMetadataTypeRedactor(type)` gains a third resolution — after explicit registrations and built-ins — a redactor derived from the credential markers on the type's registered schema. Every meta read door now withholds a webhook's `secret` and `headers`, with or without `@objectstack/plugin-webhooks` mounted (ruling B+ on #24966).
+
+ **BREAKING:** the `/meta` read doors no longer serve a webhook's `secret` or `headers`.
+
+ Clause-②: no (narrowing) — the `/meta` read doors return less for `webhook`: a stored `secret` or non-empty `headers` map is dropped from the served item and named in `redactedKeys`; the cleared forms (an empty string, an empty map) are served as written. A save that omits the two fields keeps the stored values (the metadata protocol's carry-forward); to remove a credential, save its cleared form. The dispatcher reads the declaration itself, so delivery signing is unchanged.
+
+ A metadata type that stores a credential in a declared field marks it on its schema instead of registering a redactor; a spec ratchet reds on a credential-named field that carries no marker.
+
+
+- 045858f: feat(spec): a failed flow run can carry the refusal its write node met — `AutomationResult.userMessage` / `AutomationResult.innerRefusal`, and one `FlowFailureDetailsSchema` for every door's `400 FLOW_FAILED` details
+
+ Clause-②: yes (widening)
+
+ The maintainer's 「同意」 on ruling B′ of #24914 (decision batch #379 item 5): the inner refusal of a refused flow write node reaches the client structurally, the way a record save's refusal does, and its code takes the wire spelling. This release is the contract half. The engine and the four doors start emitting the new members when the services and runtime half (#24914) lands; until then nothing on the wire changes.
+
+ - **`AutomationResult` (`@objectstack/spec/contracts`) gains two optional members.** `userMessage` has `ApiErrorSchema.userMessage`'s semantics: it is producer-marked end-user text, present only when the inner refusal's producer opted in at throw time, and it never replaces `error`. `innerRefusal` is `{ code, message }`. Its `code` is the wire spelling a record save of the same write answers in `error.code` (`UNIQUE_VIOLATION` for a unique refusal), parsed against the same closed `ErrorCode` set `ApiErrorSchema.code` uses. `TriggerFlowResponseSchema.data` mirrors both, so the `TriggerFlowResponse['data']` = `AutomationResult` parity pin holds.
+ - **New `FlowInnerRefusalSchema` and `FlowFailureDetailsSchema` (`@objectstack/spec/api`).** The second one is the `error.details` of the `400 FLOW_FAILED` arm of the trigger, resume, `/actions` and endpoint doors, declared once: `errorMessage?`, `summary?` and `innerRefusal?`. On the resume door, `ResumeFailureDetailsSchema`'s `runId` / `status` / `repairable` sit beside them in the same object, and each schema parses its own members.
+ - **The #9585 `/actions` ceiling, relaxed by the ruling.** That ceiling was exactly two keys in `error.details` (`errorMessage`, `summary`) and no new schema key. Ruling B′ adds one key and names it, `innerRefusal`, so the ceiling is now the three keys `FlowFailureDetailsSchema` declares, at every door.
+ - **Unchanged.** `error.message` keeps each door's wording byte for byte, `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union the #9378 ruling settled. The inner code is nested in `innerRefusal`, so no new ledger code is added. Every new member is optional: an envelope without them parses exactly as before.
+
+ **For consumers.** A flow runner can show a failed run's refusal the way a record form shows a save's refusal. Read the envelope's top-level `userMessage` when it is present. Otherwise read `details.innerRefusal` (`code` and `message`), and fall back to `message` last. A client running behind the server ignores the new members and keeps its current behaviour.
+- baedcec: feat(spec)!: `defineStack` refuses a stack that declares a webhook while `requires` lacks `'webhooks'` or `'realtime'`, with `STACK_WEBHOOK_CAPABILITY_REQUIRED`
+
+ A declared webhook is dispatched by two capabilities together: `'webhooks'` mounts `@objectstack/plugin-webhooks`, whose auto-enqueuer turns a record event into a delivery, and `'realtime'` mounts `@objectstack/service-realtime`, that auto-enqueuer's only source of record events. Neither token implies the other, and a scaffolded app's `requires` is `['automation', 'triggers']`, which carries neither. So a stack that declared a webhook without both validated, built and booted; the activation door answered `enabled: true`; every record write answered `200`; and no delivery was ever enqueued. The only trace was one boot `error`, after deploy. `defineStack` now refuses such a stack at load, the third declared-capability refusal beside `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` and `STACK_TRIGGER_CAPABILITY_REQUIRED`, so every door that loads it (`os validate`, `os build`, `os serve` / `os dev`, an app's own `bootStack` tests) stops with the same sentence.
+
+ Clause-②: no (narrowing)
+
+
+
+ **BREAKING** (the `defineStack` accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
+
+ - **FROM:** a stack with a non-empty top-level `webhooks:` collection and a `requires` missing `'webhooks'`, `'realtime'` or both (an absent `requires` included) was accepted, and its webhooks never delivered.
+ - **TO:** `defineStack` throws `STACK_WEBHOOK_CAPABILITY_REQUIRED` (`status: 422`), header `defineStack webhook capability validation failed (N issues):`, with one `issues` entry per declared webhook naming the webhook and the missing token or tokens. One code for every arm: which token is missing is in the finding, not in the code.
+ - **Unchanged:** a stack with no declared webhook owes neither token; `defineStack(config, { strict: false })` skips the judgement by contract; the activation door answers as before; and the boot `error` that `@objectstack/plugin-webhooks` logs for a host that wires a stack by hand, past `defineStack`, stays. Every declared webhook counts, `isActive: false` included, because the activation door can switch it on.
+ - **The fix for an author:** add both tokens to the stack's `requires`, beside whatever it already declares, for example `requires: ['automation', 'triggers', 'webhooks', 'realtime']`. The refusal's own line names exactly the tokens to add.
+- 386f60f: fix(spec)!: the `Field.select` string shorthand keeps every character it does not document transforming, so an option such as `'no-overlay'` is refused instead of being declared as a different value
+
+ Clause-②: no (narrowing: a `Field.select` shorthand string the helper used to rewrite silently, e.g. `'no-overlay'` → `nooverlay`, is refused instead)
+
+ Metadata-protocol: narrowed
+
+
+
+ **BREAKING** (a TypeScript author's shorthand that used to be rewritten is now refused), shipped as `minor` under the launch-window convention for breaking changes.
+
+ `Field.select(['a', 'b'])` and `Field.select({ options: ['a', 'b'] })` build each option as `{ label: s, value: … }`. The value used to be the string lower-cased, with whitespace turned into `_`, and then with every character outside `a-z`, `0-9` and `_` deleted. That last step was undocumented and silent: `'no-overlay'` became `nooverlay`, `'user-agent-based'` became `useragentbased`, `'v1.2'` became `v12`, and the field declared a value its author never wrote, so a record written with the value as written was refused with `invalid_option`.
+
+ - **FROM:** a shorthand string is lower-cased, whitespace becomes `_`, and every other character outside `a-z0-9_` is deleted. `Field.select(['no-overlay'])` declares the option value `nooverlay`, and the object loads.
+ - **TO:** a shorthand string is lower-cased and whitespace becomes `_`, nothing else. `Field.select(['no-overlay'])` declares `no-overlay`, which is not a system identifier, so the object is refused by the rule an explicit `{ value: 'no-overlay' }` already met: `ObjectSchema.create` throws a `ZodError` at module load with `invalid_format` at `fields.FIELD.options.N.value` and the message `System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots`, and the object save door answers `422 INVALID_METADATA` with the same finding. A dot is now kept (`'v1.2'` declares `v1.2`, which is a valid identifier).
+ - **Unchanged:** `Field.select(['In Progress', 'Closed Won'])` still declares `in_progress` and `closed_won`. An explicit option's value is still lower-cased. `SelectOptionSchema.value` accepts and refuses exactly what it did before.
+ - **The fix:** write such an option explicitly, with the value you will store: `Field.select({ options: [{ label: 'no-overlay', value: 'no_overlay' }] })`. If rows already hold the hyphenated spelling, the column is not a select: declare it as `Field.text({ maxLength: 255 })`.
+
+ `@objectstack/metadata-core`: `sys_metadata_audit.lock_state` was the one in-tree column relying on the strip. It declared the options `none`, `nooverlay`, `nodelete` and `full`, while its writer stores the `MetadataLock` spelling (`none`, `no-overlay`, `no-delete`, `full`), so no `no-overlay` or `no-delete` row ever matched a declared option. It is now `Field.text({ maxLength: 255 })`, read-only as before. `GET /api/v1/meta/object/sys_metadata_audit` serves `lock_state` as `type: 'text'` with `maxLength: 255` and no `options`. No stored row is rewritten, the writer and reader are unchanged, and an existing `varchar(255)` column reports no schema drift on postgres, mysql or sqlite.
+
+ `@objectstack/platform-objects`: the translation bundles drop the four `sys_metadata_audit.lock_state` option labels (`none`, `nooverlay`, `nodelete`, `full`), which the column no longer declares. The field label stays translated.
+- 28c9a2c: fix(spec,driver-turso)!: a turso datasource that declares `encryptionKey` on a local file or an embedded replica is refused where it is written and when the driver is built, instead of being accepted and written in plaintext
+
+ Clause-②: no (narrowing) — the accept set of the `turso` `datasource.config` contract, of the published `TursoConfigSchema` mirror and of `new TursoDriver()` narrows by one key beside a `file:` url or `:memory:`, in every mode. No key is added, removed or renamed, and no exported symbol moves. `@objectstack/service-datasource` is graded `patch`: the TSDoc of its exported `TursoDriverConfigInput.encryptionKey` no longer promises an encrypted file, and nothing else in it changes.
+
+ `encryptionKey` was declared as an "AES-256 encryption key for the local database file", and nothing applied it. The turso driver reads and writes a local or replica file through better-sqlite3, which has no encryption codec (`PRAGMA key` is a silent no-op there). Measured on the driver source with a test key: a local datasource with `encryptionKey` constructed, connected and wrote its rows, and plain better-sqlite3, opening the file read-only with no key, read them back. An embedded replica fared no better: only its sync client was handed the key, after the local engine had already written the file in plaintext, and a file that client encrypted is one the local engine cannot open. An operator who declared the key got an unencrypted file and no signal.
+
+ **BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). A non-empty `encryptionKey` on a local datasource (a `file:` url or `:memory:`) or an embedded replica (a `file:` url beside `syncUrl`), detected or forced with `mode`, is refused now, at both doors together, with one message per mode that names the mode and what does encrypt. The key is judged by where the database lives, not by the mode: the same `file:` url or `:memory:` under a forced `mode: 'remote'` is refused too, in its own words, because the driver hands that url to the libSQL client it builds, which is handed no key, so the file was written in plaintext all the same:
+
+ - **at authoring**, as one `custom` issue on `encryptionKey` (`config.encryptionKey` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
+ - **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any database file is opened.
+
+ For a local or replica datasource the message is the same text at all three places; for a `file:` url or `:memory:` under a forced `mode: 'remote'`, the schema and the constructor give the forced-remote text, while the driver mirror, which declares no `mode` and strips it, refuses the same config in the local text. A test holds the constructor's copy equal to the schema's issue byte for byte in every mode, and the mirror's verdict and words equal to the schema's wherever no `mode` is forced. It comes last among the local-engine refusals: a url the engine cannot open, a lone `sync` and a forced `mode` the driver would ignore each keep their own message. An empty `encryptionKey` is unset and is still accepted. A remote url (`libsql://`, `https://`, `ws(s)://`) is not judged by this refusal. The `encryptionKey` describe texts no longer promise an encrypted file.
+
+ ### Migration: FROM → TO
+
+ | You wrote | Write instead |
+ | --- | --- |
+ | `url: 'file:./data/app.db', encryptionKey: '…'` | drop `encryptionKey`; put the file on an encrypted disk or volume if it must be encrypted at rest |
+ | `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io', encryptionKey: '…'` | drop `encryptionKey`, the same way |
+ | `url: 'file:./data/app.db', mode: 'remote', encryptionKey: '…'` (or `url: ':memory:'`) | drop `encryptionKey`, the same way |
+ | either, where the data must be encrypted at rest and no encrypted volume is available | a remote Turso database (`url: 'libsql://my-db.turso.io'`), whose storage encryption is the server's to configure, not this key's |
+
+ The fix in one line: delete `encryptionKey` from every turso datasource whose `url` is a `file:` url or `:memory:`.
+
+ A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical. Otherwise it is left unconnected with a warning. Before this change the same row booted and wrote its file in plaintext.
+
+ Blast radius, measured on this tree: no example, template, CLI scaffold or published skill authors the key, and no host default or environment variable sets it (a turso `encryptionKey` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.
+
+
+
+### Patch Changes
+
+- df8bb40: **Shipped in 18.0.0**, whose CHANGELOG missed this entry; listed here one release late.
+
+ feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
+
+ Clause-②: yes (narrowing)
+
+
+
+ **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
+
+ - **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
+ - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
+ - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
+ - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
+
+ **The remedy.**
+ - A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
+ - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
+
+ The retired export:
+
+ - FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
+- 0091d84: fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
+
+ Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
+
+ ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
+
+ - **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
+ - **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
+ - **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
+ - **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
+ - **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
+ - **The other packages.** Code comments only.
+- 5e9b100: feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database
+
+ Clause-②: yes (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** accept-set narrowing at the database (ADR-0131 D1: "There is no nullable tenant column anywhere"). It changes what a data writer can store, not what a metadata author can write, so it ships as `patch` with this banner.
+
+ - **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns.
+ - **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed: .organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict.
+ - **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. On a database that has not taken the ceremony, a table this release creates (a newly rotated shard, or a cold table a newly configured lifecycle tier creates) is NOT NULL beside nullable siblings: an organization-less row the lifecycle mover would carry into it is refused there, and stays where it is until the ceremony attributes it. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`.
+ - **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly.
+
+ **The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`.
+- e7befa7: feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
+
+ Clause-②: yes (widening)
+
+ - **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
+ - **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '' }` (`Field.lookup('', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
+ - **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
+ - **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
+ - **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
+ - **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.
+- 55d7aab: docs(spec): `server.trustProxy`'s description names both consumers of the client-address rule
+
+ Clause-②: no
+
+ The `.describe()` on `server.trustProxy` named only the inbound rate limiter. `objectstack serve` also passes the value to the authentication plugin, whose IP allow-list (`allowed_ip_ranges`) judges, and whose login audit stamps as `last_login_ip`, the address that the same rule establishes (`@objectstack/plugin-auth/client-address`). The description now names both. No key, type, default or accepted value changes.
+- 0715fac: fix(spec): `os lint` no longer tells authors to remove a declared webhook's `label` or `description`
+
+ Clause-②: no (a liveness-ledger re-grade: no schema, accept set, served body or error code moves; only the advisory `liveness-dead-property` warning on these two keys stops)
+
+ The shipped liveness ledger (`liveness/webhook.json`) graded a declared webhook's `label` and `description` `dead` once the boot seeder that copied them into a `sys_webhook` row retired, and the advisory lint warned "Remove it" on every webhook that authored them. They are read: the Studio metadata list and quick-find show both keys, read off `GET /api/v1/meta/webhook`. Both rows are now `live`, so a declared webhook may keep its `label` and `description` and no warning is printed for them. Nothing else changes: the dispatcher still delivers on the same props as before.
+- 1862d8d: fix(spec): the `script.config.template` tombstone points at the `notify` node's own `template` key
+
+ Clause-②: no (prose only: no shape, key, default or accepted value moves)
+
+ The rejection an author sees for the retired `script.config.template` key said that stored templates live in the messaging service (`sys_notification_template`). That text predates the `notify` node's own `template` key. It now prescribes that key: name an email template (`email_template`) in a `notify` node's `template`, with `templateData` filling its placeholders, and the email and in-app inbox channels render it in each recipient's locale; otherwise the node carries inline `title`/`message`. It mentions `sys_notification_template` only as the rows the `email` and `sms` channels read, `email` only when the node names no template. The key stays rejected exactly as before, and the generated reference page carries the same text.
+- 69712d5: docs(spec): `ApprovalRequestRow`'s display labels are documented as the reader's-locale label
+
+ Clause-②: no (ledger and docblock text only: no shape, key, default, accepted value or export moves)
+
+ The `ApprovalRequestRow` docblocks for `process_label`, `step_label`, `current_step_label` and `object_label` named the authored or schema label. Since plugin-approvals serves those members in the reader's locale, they now say so: the served value is the translation picked from `flows..label`, `flows..approvals..label` or `objects..label` in the read's `ExecutionContext.locale`, falling back to the stored snapshot (or the object's schema label). The stored `sys_approval_request` columns are unchanged. The `flows.approvals` translation liveness rows read `live`. No type, key or export changes.
+- 03c12a5: fix(objectql)!: under `strictReadonlyWrites`, a write whose value a stored field's `expression` replaces (`stored_expression`) is refused, and nothing is written
+
+ Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`)
+
+
+
+ **BREAKING** (the strict mode's refusal set grows by one reason), shipped as `minor` under the amended ruling 「甲」: a narrowing that no conversion carries ships now, in 18.x. It applies only to a caller that opts into the in-process `strictReadonlyWrites` option. No REST or wire caller can set that option, and every write without it behaves exactly as before.
+
+ `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed, one reported drop was left out: a caller's value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict, such a write completed and was only reported. It is now refused like every other strip's drop.
+
+ - **FROM:** a write with `strictReadonlyWrites: true` that sent a value for a stored expression field, different from the value the engine computes, completed. The row held the computed value, and `onFieldsDropped` received a `stored_expression` event.
+ - **TO:** the same write throws `ReadonlyFieldRejectedError` (`code: 'ERR_READONLY_FIELD_REJECTED'`), whose `drops` carries the `stored_expression` event. Nothing is written, and `onFieldsDropped` does not fire. This holds on insert (a single row, a batch, and `insertMany`, which refuses the whole call as it does for the other strict drops), on update by id, and on a predicate update.
+ - **One refusal names everything.** When the same payload also carries another strip's drop (`readonly`, `readonly_when`, `primary_key`, `computed`), the one refusal names all of them together, `stored_expression` last. Before, such a write was refused for the other drops alone. The message's per-reason form gains a `stored_expression` remedy, spoken only when that reason is present, so every other refusal's message is unchanged.
+ - **Ordering is kept.** The strips' refusal still comes first: if the stored-expression pass itself throws, for example a predicate update whose patch computes different values on different matched rows (`VALIDATION_FAILED`), a strict write that also carries a strip's drop is still refused with `ERR_READONLY_FIELD_REJECTED` naming those drops, as before.
+ - **`@objectstack/spec` (patch):** the TSDoc on `WriteObservabilityOptions.strictReadonlyWrites` and `DroppedFieldsEvent.reason`, which ships in the published declarations, now says the `stored_expression` arm is refused under strict. No schema, `.describe()` text or export changes.
+ - **Unchanged:** a strict write that sends the computed value, or no value for the field, is not refused, because nothing it sent was replaced. A stored expression field also declared `readonly` is still refused once, as `readonly`. Without `strictReadonlyWrites`, the write completes with the computed value and the replacement is reported, as before. The error code is unchanged.
+ - **The fix for a strict caller:** leave the stored expression field out of the payload, or send the value its expression computes. To let the replacement happen and merely observe it, drop `strictReadonlyWrites` and pass `onFieldsDropped`.
+
+ This supersedes the `strictReadonlyWrites` bullet of the `stored_expression` reason's entry, which deferred this refusal to a later major.
+
+ Measured before the change: no non-test caller in this repository passes `strictReadonlyWrites` (the examples, dogfood suites and runtime doors pass none), so no in-repo producer moves.
+- edcc8ce: docs(spec): `server.trustProxy`'s description names all three consumers of the client-address rule
+
+ Clause-②: no (prose only: no shape, key, default or accepted value moves)
+
+ The `.describe()` on `server.trustProxy` said the client-address rule had two consumers: the inbound rate limiter and the authentication plugin. There is a third. A declared endpoint (`apis:`) whose own `rateLimit` is enabled is metered by the endpoint policy chain, which keys anonymous callers by the same rule under the same `trustProxy`, whether or not `server.security.rateLimit.enabled` is set. The description and the TSDoc now name it. No key, type, default or accepted value changes.
+- fccbd9d: feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered
+
+ Clause-②: yes (widening: an optional member on `IEmailService`)
+ Metadata-protocol: none
+
+ - **The contract.** `IEmailService` (`@objectstack/spec/contracts`) gains one optional member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. `true` means the active transport hands mail to a real delivery provider. `false` means no mail leaves the process: sends still resolve and still write their audit row. A `log` transport is never delivering, in any environment. The answer is read at call time and follows a transport swapped in at runtime.
+ - **Absent means unknown.** The member is optional, so every existing implementation stays valid. A consumer must never read an absent member as delivering: a flow whose success depends on a mail reaching someone treats absent the same as `false` and keeps its explicit refusal, so a service that is merely wired is never mistaken for one that delivers.
+ - **The implementation.** `@objectstack/plugin-email`'s `EmailService.isConfigured()` answers `false` while its transport is the `LogTransport` (the default when no mail transport is configured) and `true` for any other transport. It follows the transport the plugin rebuilds from the `mail` settings, so saving an SMTP or API-provider configuration turns it `true` without a restart.
+ - **Unchanged.** Sending, templates, persistence and the boot warning for the `log` transport behave as before. No consumer reads the member yet.
+- 03966ce: fix(plugin-auth)!: lift the better-auth family from 1.7.3 to 1.7.12 for the security fixes published since
+
+ `@objectstack/plugin-auth` declares the better-auth family exactly, so a fresh install resolves what this package tested. All five declared ranges (`better-auth`, `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim`, `@better-auth/sso`) move from `1.7.3` to `1.7.12`, still exact. The lift takes the fixes published in 1.7.4 to 1.7.12 for packages this plugin mounts: GHSA-965c-763c-88jm and GHSA-r4xp-prcw-77qf (1.7.7), and the oauth-provider, SSO, SCIM, JWT and password-reset fixes up to 1.7.12. Every symbol this package imports from the family is still exported. It also pulls in `better-call` 1.4.4, `@better-auth/utils` 0.5.0 and `@better-fetch/fetch` 1.3.2, the versions 1.7.12 declares.
+
+ Clause-②: no (narrowing)
+ Metadata-protocol: none
+
+
+
+ **BREAKING** (the auth endpoints refuse some requests 1.7.3 accepted, and some refusals change status), shipped as `patch` under ruling A: it changes nothing a metadata author can write, and a non-protocol breaking change ships `patch` while keeping this BREAKING banner and its ADR-0087 disposition.
+
+ - **FROM:** a Magic Link, OAuth or social sign-in, SAML or account-linking flow started before the upgrade could complete after it.
+ **TO:** it fails, and the user starts it again. 1.7.7 stores Magic Link and OAuth-state verification rows under new identifier prefixes and keys the OAuth state cookie per purpose, so a link or a pending identity-provider round-trip minted by 1.7.3 is not found: a Magic Link answers `?error=INVALID_TOKEN`, an OAuth callback answers a state mismatch.
+ - **FROM:** an OIDC SSO provider whose configured issuer differed from its discovery document's `issuer`, for example only by a trailing slash, signed users in.
+ **TO:** the provider is refused with `issuer_mismatch`. 1.7.12 also refuses an OIDC ID token that carries no `exp` or `iat`.
+ - **FROM:** `POST /api/v1/auth/oauth2/token` with a PKCE `code_verifier` that does not match the challenge answered `401`.
+ **TO:** it answers `400` with `error: 'invalid_grant'`.
+ - **FROM:** `GET /api/v1/auth/get-session` answered `401 UNAUTHENTICATED` ("Sign in first") when the session store could not be read, the same answer a signed-out browser gets, and an in-process `api.getSession` resolved `null`.
+ **TO:** it answers `500` with `code: 'FAILED_TO_GET_SESSION'` and keeps the session cookie, and the in-process read rejects. Callers that treat a failed read as anonymous on purpose (the request-context resolution in `@objectstack/core` and `@objectstack/runtime`) still do.
+ - **FROM:** `POST /api/v1/auth/sign-in/email` with a password longer than the configured maximum answered as an ordinary failed sign-in.
+ **TO:** it answers `400` with `code: 'PASSWORD_TOO_LONG'` before any account is looked up. It still counts as a failed attempt for the account lockout.
+ - **FROM:** a DPoP proof sent to the token endpoint was checked against the URL of the request as it reached the server.
+ **TO:** it is checked against the configured public base URL plus the request path. Behind a proxy that rewrites the host, a proof bound to the public URL is now accepted and a proof bound to the internal URL is refused with `invalid_dpop_proof`.
+ - **FROM:** `GET /api/v1/auth/get-session` with a cookie for a session that no longer exists answered `401` and left the cookie in place.
+ **TO:** the same `401` also clears the stale session cookies.
+ - **New route:** `POST /api/v1/auth/oauth2/client/rotate-keys` (`private_key_jwt` key rotation) is now published, and refused with `403 access_denied` because this package does not enable client key rotation.
+ - **Unchanged:** the package's exports and configuration; sign-up, sign-in and session cookies (`HttpOnly; SameSite=Lax; Path=/`, no `Secure` on plain HTTP); the active organization stamped on a new session; implicit account linking and its unlink records; single-use reservations (a replayed DPoP proof or client assertion is still refused); and where sessions and verification values are stored with or without a host `secondaryStorage`.
+ - **`@objectstack/spec` and `@objectstack/cli` (patch, text only, not breaking):** the TSDoc on the organization schema's optional `updatedAt` now records that its better-auth measurement still holds on 1.7.12, and the `os migrate account-issuer` description no longer names a better-auth version. No schema, type, flag or behaviour changes in either package.
+ - **`@objectstack/service-datasource` (patch, text only, not breaking):** the `resolveSqliteDriver` docs now describe the error better-sqlite3 13 actually throws for an addon that was never built: its loader's `require()` of the absent `build/Release/better_sqlite3.node`, which Node answers with `MODULE_NOT_FOUND`. The docs used to name a helper package's message that 13.x no longer produces. Development still steps down to wasm SQLite on that error, as before. No code changes.
+ - **What to do as an operator:** tell users that a Magic Link, social, SSO or account-linking sign-in begun before the upgrade must be started again after it. Check each OIDC SSO provider's configured issuer against its discovery document and set it to the exact `issuer` string the document publishes. An OAuth client that branched on `401` for a PKCE failure should branch on `400 invalid_grant`. A browser client that read every non-`200` `/get-session` answer as "signed out" should treat a `500` as "try again".
+
## 18.0.0
### Major Changes
diff --git a/packages/spec/package.json b/packages/spec/package.json
index a89d50fff9e..215cd3340e6 100644
--- a/packages/spec/package.json
+++ b/packages/spec/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/spec",
- "version": "18.0.0",
+ "version": "18.1.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 caea94bbde7..5441554bb8d 100644
--- a/packages/triggers/trigger-api/CHANGELOG.md
+++ b/packages/triggers/trigger-api/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/trigger-api
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Major Changes
diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json
index c04d9af598e..d5885238e1d 100644
--- a/packages/triggers/trigger-api/package.json
+++ b/packages/triggers/trigger-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/trigger-api",
- "version": "18.0.0",
+ "version": "18.1.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 37c62e9d9e2..fa55d85fa62 100644
--- a/packages/triggers/trigger-record-change/CHANGELOG.md
+++ b/packages/triggers/trigger-record-change/CHANGELOG.md
@@ -1,5 +1,40 @@
# @objectstack/plugin-trigger-record-change
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json
index ef9eca7ddd1..cab9073458c 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": "18.0.0",
+ "version": "18.1.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 e519dc49e95..ef8ca21485d 100644
--- a/packages/triggers/trigger-schedule/CHANGELOG.md
+++ b/packages/triggers/trigger-schedule/CHANGELOG.md
@@ -1,5 +1,43 @@
# @objectstack/plugin-trigger-schedule
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [b42c621]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [644e4b2]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/metadata-core@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Patch Changes
diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json
index 65b53f3f982..ef5ab9d2548 100644
--- a/packages/triggers/trigger-schedule/package.json
+++ b/packages/triggers/trigger-schedule/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/trigger-schedule",
- "version": "18.0.0",
+ "version": "18.1.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 197bb04330b..76d5fe28cbd 100644
--- a/packages/types/CHANGELOG.md
+++ b/packages/types/CHANGELOG.md
@@ -1,5 +1,36 @@
# @objectstack/types
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [e7befa7]
+- Updated dependencies [55d7aab]
+- Updated dependencies [0715fac]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [69712d5]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [386f60f]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [03966ce]
+ - @objectstack/spec@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/types/package.json b/packages/types/package.json
index f61447d18e7..89c258feb51 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/types",
- "version": "18.0.0",
+ "version": "18.1.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 8e456bb4c72..fe30f2bb03b 100644
--- a/packages/verify/CHANGELOG.md
+++ b/packages/verify/CHANGELOG.md
@@ -1,5 +1,137 @@
# @objectstack/verify
+## 18.1.0
+
+### Patch Changes
+
+- Updated dependencies [94842ba]
+- Updated dependencies [7cacad5]
+- Updated dependencies [df8bb40]
+- Updated dependencies [0091d84]
+- Updated dependencies [5e9b100]
+- Updated dependencies [7532ea3]
+- Updated dependencies [fde7456]
+- Updated dependencies [de9cd1f]
+- Updated dependencies [9a06f0b]
+- Updated dependencies [f90ea67]
+- Updated dependencies [7035682]
+- Updated dependencies [d606fc1]
+- Updated dependencies [c2d4072]
+- Updated dependencies [a8b44d4]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [78ab647]
+- Updated dependencies [abd5acd]
+- Updated dependencies [6b9e6df]
+- Updated dependencies [fbb42d9]
+- Updated dependencies [73a2b52]
+- Updated dependencies [950f949]
+- Updated dependencies [5872450]
+- Updated dependencies [9ea5afe]
+- Updated dependencies [6440cf1]
+- Updated dependencies [72518d2]
+- Updated dependencies [e5b3de0]
+- Updated dependencies [ad8cf92]
+- Updated dependencies [e7befa7]
+- Updated dependencies [02e6d73]
+- Updated dependencies [4cef6e2]
+- Updated dependencies [28ff8a9]
+- Updated dependencies [55d7aab]
+- Updated dependencies [98c3d00]
+- Updated dependencies [5050442]
+- Updated dependencies [2b1382a]
+- Updated dependencies [3fbef41]
+- Updated dependencies [fcb40ad]
+- Updated dependencies [ba69b16]
+- Updated dependencies [865d2c8]
+- Updated dependencies [865d2c8]
+- Updated dependencies [2941a19]
+- Updated dependencies [3b3830c]
+- Updated dependencies [644e4b2]
+- Updated dependencies [3d8960c]
+- Updated dependencies [0715fac]
+- Updated dependencies [ea08ce5]
+- Updated dependencies [19e5338]
+- Updated dependencies [b9a79ef]
+- Updated dependencies [63085c3]
+- Updated dependencies [0603bfa]
+- Updated dependencies [5d2c5a1]
+- Updated dependencies [b811c73]
+- Updated dependencies [066dabc]
+- Updated dependencies [4b8f597]
+- Updated dependencies [f802213]
+- Updated dependencies [86d2360]
+- Updated dependencies [688052e]
+- Updated dependencies [5d27d32]
+- Updated dependencies [6b656bf]
+- Updated dependencies [7f99616]
+- Updated dependencies [9532e27]
+- Updated dependencies [157ebd0]
+- Updated dependencies [1862d8d]
+- Updated dependencies [30d21f6]
+- Updated dependencies [eddffc3]
+- Updated dependencies [58cd58f]
+- Updated dependencies [58cd58f]
+- Updated dependencies [69712d5]
+- Updated dependencies [dfe60e3]
+- Updated dependencies [d68aab6]
+- Updated dependencies [aaee084]
+- Updated dependencies [e939357]
+- Updated dependencies [045858f]
+- Updated dependencies [baedcec]
+- Updated dependencies [03c12a5]
+- Updated dependencies [f8c5fb3]
+- Updated dependencies [d96f618]
+- Updated dependencies [b289090]
+- Updated dependencies [386f60f]
+- Updated dependencies [d6a5fa3]
+- Updated dependencies [28c9a2c]
+- Updated dependencies [4bf079c]
+- Updated dependencies [f46ef4d]
+- Updated dependencies [3782b2d]
+- Updated dependencies [ba244f1]
+- Updated dependencies [edcc8ce]
+- Updated dependencies [a39389e]
+- Updated dependencies [2e242bb]
+- Updated dependencies [fccbd9d]
+- Updated dependencies [4f97aeb]
+- Updated dependencies [db9c02a]
+- Updated dependencies [03966ce]
+ - @objectstack/core@18.1.0
+ - @objectstack/plugin-security@18.1.0
+ - @objectstack/runtime@18.1.0
+ - @objectstack/spec@18.1.0
+ - @objectstack/plugin-sharing@18.1.0
+ - @objectstack/plugin-approvals@18.1.0
+ - @objectstack/objectql@18.1.0
+ - @objectstack/plugin-webhooks@18.1.0
+ - @objectstack/service-automation@18.1.0
+ - @objectstack/service-messaging@18.1.0
+ - @objectstack/service-storage@18.1.0
+ - @objectstack/plugin-auth@18.1.0
+ - @objectstack/platform-objects@18.1.0
+ - @objectstack/service-settings@18.1.0
+ - @objectstack/plugin-email@18.1.0
+ - @objectstack/service-sms@18.1.0
+ - @objectstack/service-datasource@18.1.0
+ - @objectstack/plugin-audit@18.1.0
+ - @objectstack/plugin-hono-server@18.1.0
+ - @objectstack/rest@18.1.0
+ - @objectstack/mcp@18.1.0
+ - @objectstack/metadata@18.1.0
+ - @objectstack/plugin-pinyin-search@18.1.0
+ - @objectstack/service-analytics@18.1.0
+ - @objectstack/service-cache@18.1.0
+ - @objectstack/service-job@18.1.0
+ - @objectstack/service-mailbox@18.1.0
+ - @objectstack/service-package@18.1.0
+ - @objectstack/service-queue@18.1.0
+ - @objectstack/service-realtime@18.1.0
+ - @objectstack/trigger-api@18.1.0
+ - @objectstack/trigger-record-change@18.1.0
+ - @objectstack/trigger-schedule@18.1.0
+ - @objectstack/types@18.1.0
+
## 18.0.0
### Minor Changes
diff --git a/packages/verify/package.json b/packages/verify/package.json
index 91d9c8da1e6..9f918186b62 100644
--- a/packages/verify/package.json
+++ b/packages/verify/package.json
@@ -1,6 +1,6 @@
{
"name": "@objectstack/verify",
- "version": "18.0.0",
+ "version": "18.1.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 1ce24ceee8e..054faf12f87 100644
--- a/skills/objectstack-ai/SKILL.md
+++ b/skills/objectstack-ai/SKILL.md
@@ -12,7 +12,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 ef4aaebab21..9dc33c66ce8 100644
--- a/skills/objectstack-api/SKILL.md
+++ b/skills/objectstack-api/SKILL.md
@@ -14,7 +14,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.0"
domain: api
tags: rest, endpoint, auth, realtime, server
---
diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md
index d1beab6fcae..7e85ab9c1aa 100644
--- a/skills/objectstack-automation/SKILL.md
+++ b/skills/objectstack-automation/SKILL.md
@@ -13,7 +13,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 bff697b6674..a6a0601faa9 100644
--- a/skills/objectstack-data/SKILL.md
+++ b/skills/objectstack-data/SKILL.md
@@ -15,7 +15,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 a70bf200f00..c0b3596e833 100644
--- a/skills/objectstack-formula/SKILL.md
+++ b/skills/objectstack-formula/SKILL.md
@@ -13,7 +13,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x and @objectstack/formula 18.x (CEL)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 6ee0ce4c140..307f481ffc1 100644
--- a/skills/objectstack-i18n/SKILL.md
+++ b/skills/objectstack-i18n/SKILL.md
@@ -13,7 +13,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 e20868689be..738a75c4451 100644
--- a/skills/objectstack-platform/SKILL.md
+++ b/skills/objectstack-platform/SKILL.md
@@ -17,7 +17,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x and @objectstack/core 18.x (Zod v4 schemas), Node 22+
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 52eb498efcf..7f885f77f85 100644
--- a/skills/objectstack-query/SKILL.md
+++ b/skills/objectstack-query/SKILL.md
@@ -11,7 +11,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 234fd045d4a..40e1ec11672 100644
--- a/skills/objectstack-ui/SKILL.md
+++ b/skills/objectstack-ui/SKILL.md
@@ -18,7 +18,7 @@ license: Apache-2.0
compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas)
metadata:
author: objectstack-ai
- version: "18.0.0"
+ version: "18.1.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 250a9df504c..7e14dfb5442 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: "18.0.0"
+ version: "18.1.0"
domain: process
tags: upgrade, migration, protocol, major, retired-keys, tombstone, conversions, validate, report
---