From 3c9d3a4c09a01b13a46c7bd3f1c19f53f6053275 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:13:52 +0000 Subject: [PATCH] chore: version packages --- .changeset/24446-u1-access-upgrade-report.md | 34 - ...6-u2-marketplace-access-upgrade-refusal.md | 25 - ...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-plain-organization-key-part.md | 20 - .../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 - .../24837-managed-datasource-sync-refused.md | 17 - .../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 - ...admin-guard-default-organization-anchor.md | 22 - .changeset/24895-preference-user-reference.md | 23 - .../24896-prelude-grant-read-projection.md | 9 - .changeset/24899-archived-mail-bodies.md | 19 - .../24900-webhook-label-description-live.md | 9 - .changeset/24902-tenant-text-after-step-7.md | 12 - .../24909-missing-driver-client-remedy.md | 13 - .../24910-declared-webhook-undeliverable.md | 20 - .changeset/24914-flow-inner-refusal.md | 17 - .../24915-approval-step-translation-key.md | 14 - .../24916-approval-flow-i18n-coverage.md | 29 - ...3-sys-notification-subscription-retired.md | 25 - .../24924-template-channel-email-sms.md | 14 - .../24925-plan-names-ownership-ceremony.md | 15 - .changeset/24932-cbp-leaf-switch.md | 16 - .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 - .../24952-user-field-reference-refused.md | 24 - .../24953-page-predicate-bounds-warning.md | 13 - .../24956-boot-drift-line-names-ceremony.md | 12 - .../24957-validation-rule-parent-error.md | 30 - .../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/24987-legal-name-help.md | 11 - .changeset/24991-nested-props-non-record.md | 29 - .../24993-contributed-nav-i18n-coverage.md | 24 - .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 - .changeset/25015-digest-heading-locale.md | 13 - .../25019-sqlite-file-failure-family.md | 24 - .../25019-wasm-unreadable-file-refused.md | 18 - .../25020-turso-encryption-key-local.md | 35 - .../25030-preflight-issuer-physical-read.md | 16 - .../25031-blocked-unique-index-apply-limit.md | 16 - ...034-delegated-caller-reads-through-leaf.md | 13 - .changeset/25044-cbp-every-master-edit.md | 19 - .changeset/25049-cbp-every-row-master.md | 31 - .../25050-retire-turso-encryption-key.md | 46 -- .../25053-oauth-application-type-options.md | 18 - .../25058-trustproxy-three-consumers.md | 9 - .../25059-org-delete-read-fault-refuses.md | 18 - .changeset/25060-signup-existing-address.md | 22 - ...25061-notification-template-html-escape.md | 18 - .../25062-objectql-write-wall-verdict.md | 16 - ...5062-plugin-security-write-wall-verdict.md | 14 - ...25063-delegated-organization-membership.md | 18 - .../25065-email-service-is-configured.md | 14 - .changeset/25071-composite-title-stored.md | 12 - .../25073-autonumber-stored-expression.md | 20 - .../25077-rest-docs-viewer-attr-escape.md | 9 - .changeset/25078-csv-text-cell.md | 21 - .../25079-federated-report-policy-denied.md | 13 - ...82-notification-template-hole-placement.md | 19 - .changeset/25083-cloud-managed-doors.md | 22 - .../25084-sqlite-refused-connect-release.md | 13 - .changeset/25092-cbp-master-writable.md | 27 - .../25095-cbp-unwritable-master-write.md | 24 - .changeset/25096-better-auth-1-7-12.md | 37 - ...25097-cloud-connection-platform-managed.md | 20 - .changeset/25104-rest-docs-viewer-retired.md | 20 - ...scope-delegated-organization-membership.md | 17 - ...legation-probes-organization-membership.md | 18 - ...o-domain-verification-instructed-record.md | 14 - .changeset/25112-magic-link-ttl.md | 11 - .changeset/25118-dcr-infer-native.md | 19 - .changeset/25119-cloud-connection-one-rule.md | 22 - .changeset/25120-mcp-package-objects.md | 20 - .changeset/25121-spec-openapi-viewer-prose.md | 16 - .../25123-scaffold-peer-ledger-retired.md | 11 - ...25128-tenant-column-never-null-comments.md | 10 - .../25129-composite-title-stored-field.md | 15 - .../25133-refuse-orgless-production-boot.md | 24 - ...34-migrate-plan-nullable-tenant-columns.md | 17 - .changeset/25138-mcp-stdio-package-objects.md | 20 - .../25139-mcp-actions-merged-object-view.md | 20 - ...5144-cbp-unservable-master-boot-refusal.md | 34 - .../25158-refuse-plugin-auth-load-failure.md | 19 - .../25159-api-docs-viewer-keys-retired.md | 47 -- .changeset/25165-scaffold-vitest-advisory.md | 15 - ...rnal-binding-managed-datasource-refused.md | 23 - .changeset/25174-rest-uuid-advisory.md | 15 - .../25175-create-plugin-scaffold-test.md | 14 - .changeset/25175-plugin-scaffold-test.md | 12 - .../25187-declared-rule-reader-contract.md | 13 - .../25189-email-template-hole-placement.md | 26 - ...25190-retire-body-taking-render-exports.md | 18 - .../25194-delivery-title-names-recipient.md | 14 - ...-platform-object-tenancy-ledger-retired.md | 26 - ...5202-settings-no-organization-less-rung.md | 22 - .changeset/25203-adr-0131-small-carriers.md | 16 - .../25204-claim-orphan-org-rows-retired.md | 22 - ...self-registration-grant-sole-membership.md | 20 - ...208-default-organization-one-definition.md | 22 - .../25211-external-binding-save-doors.md | 30 - .changeset/25229-page-window-refused.md | 18 - .../25230-engine-declares-wall-exemption.md | 14 - ...enant-audit-insert-carries-organization.md | 14 - .changeset/25232-artifact-package-source.md | 16 - ...25245-mcp-stdio-object-resources-merged.md | 20 - ...ocalization-fallback-organization-scope.md | 22 - .../25249-settings-self-bound-organization.md | 19 - .changeset/25261-jit-idp-locale-claim.md | 17 - .changeset/25264-console-branded-head.md | 21 - .changeset/25267-cel-has-indexed-receiver.md | 15 - .changeset/25269-page-window-declared.md | 20 - .../25271-single-reslug-default-anchor.md | 19 - ...25278-restore-doors-save-refusal-family.md | 29 - ...ored-external-binding-known-datasources.md | 15 - .../25281-visibility-has-argument-refused.md | 21 - .changeset/25287-hide-2fa-display-when-off.md | 16 - .../25292-cloud-managed-hidden-doors.md | 16 - .changeset/25292-features-cloud-managed.md | 16 - .../25292-plugin-auth-serves-cloud-managed.md | 12 - .changeset/25294-is-cloud-managed.md | 14 - .../25296-rest-actions-disabled-package.md | 19 - ...7-mcp-standalone-actions-merged-listing.md | 20 - ...25302-get-record-limit-positive-integer.md | 24 - ...08-rest-global-actions-disabled-package.md | 20 - .../25315-core-security-single-definitions.md | 46 -- .changeset/25325-execution-status-missed.md | 19 - .changeset/25330-null-label-carry.md | 16 - .changeset/25331-admin-field-help.md | 24 - .changeset/25332-setup-option-labels.md | 16 - .../25333-position-list-holders-and-grants.md | 20 - .../25338-create-user-permission-set.md | 17 - .changeset/25339-create-key-expiry.md | 13 - .../25343-session-heartbeat-default-config.md | 10 - .changeset/25343-session-list-columns.md | 14 - .../25364-last-admin-guard-read-fault-code.md | 16 - .../25391-dataset-dimension-null-label.md | 14 - .changeset/console-0d12e95361b2.md | 19 - .changeset/console-56cf4d8e89b7.md | 32 - .changeset/console-5fef032b1ada.md | 13 - .changeset/console-cd14d8eeffe1.md | 13 - .changeset/console-cf153eb2e535.md | 81 -- .../setup-nav-distinct-organization-labels.md | 10 - 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 | 69 ++ examples/app-crm/package.json | 2 +- examples/app-multi-package/CHANGELOG.md | 58 ++ examples/app-multi-package/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 98 +++ examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 94 +++ examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 75 ++ examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 39 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 77 ++ packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 77 ++ packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 77 ++ packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 680 +++++++++++++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 67 ++ packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 66 ++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 175 ++++ packages/cloud-connection/package.json | 2 +- .../connectors/connector-imap/CHANGELOG.md | 4 + .../connectors/connector-imap/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 66 ++ .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 66 ++ .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 66 ++ .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 66 ++ .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 146 ++++ packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 195 +++++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 31 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 81 ++ packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 67 ++ packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 260 ++++++ packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 91 ++ .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 219 +++++ packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 92 +++ packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 406 +++++++++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 101 +++ packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 133 +++ packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 9 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 258 ++++++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 71 ++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 427 ++++++++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 58 ++ packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 321 +++++++ packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 58 ++ packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 67 ++ .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 67 ++ .../plugins/knowledge-ragflow/package.json | 2 +- packages/plugins/organizations/CHANGELOG.md | 110 +++ packages/plugins/organizations/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 174 ++++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 137 +++ packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 509 ++++++++++++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 145 ++++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 130 +++ packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 88 ++ .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 49 ++ .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 520 ++++++++++++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 193 +++++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 132 +++ packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 196 +++++ packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 58 ++ packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 15 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 176 ++++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 304 +++++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 4 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 86 ++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 131 +++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 67 ++ packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 59 ++ .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 66 ++ .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 290 +++++++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 67 ++ packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 85 ++ packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 66 ++ .../services/service-knowledge/package.json | 2 +- .../services/service-mailbox/CHANGELOG.md | 87 ++ .../services/service-mailbox/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 260 ++++++ .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 69 ++ .../services/service-package/package.json | 2 +- packages/services/service-print/CHANGELOG.md | 69 ++ packages/services/service-print/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 85 ++ packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 85 ++ .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 237 ++++++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 108 +++ packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 107 +++ .../services/service-storage/package.json | 2 +- packages/skills/CHANGELOG.md | 23 + packages/skills/package.json | 2 +- packages/spec/CHANGELOG.md | 780 ++++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 66 ++ packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 66 ++ .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 70 ++ .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 58 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 213 +++++ 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 +- 395 files changed, 11419 insertions(+), 4209 deletions(-) delete mode 100644 .changeset/24446-u1-access-upgrade-report.md delete mode 100644 .changeset/24446-u2-marketplace-access-upgrade-refusal.md 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-plain-organization-key-part.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/24837-managed-datasource-sync-refused.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/24883-last-admin-guard-default-organization-anchor.md delete mode 100644 .changeset/24895-preference-user-reference.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/24902-tenant-text-after-step-7.md delete mode 100644 .changeset/24909-missing-driver-client-remedy.md delete mode 100644 .changeset/24910-declared-webhook-undeliverable.md delete mode 100644 .changeset/24914-flow-inner-refusal.md delete mode 100644 .changeset/24915-approval-step-translation-key.md delete mode 100644 .changeset/24916-approval-flow-i18n-coverage.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/24932-cbp-leaf-switch.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/24952-user-field-reference-refused.md delete mode 100644 .changeset/24953-page-predicate-bounds-warning.md delete mode 100644 .changeset/24956-boot-drift-line-names-ceremony.md delete mode 100644 .changeset/24957-validation-rule-parent-error.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/24987-legal-name-help.md delete mode 100644 .changeset/24991-nested-props-non-record.md delete mode 100644 .changeset/24993-contributed-nav-i18n-coverage.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/25015-digest-heading-locale.md delete mode 100644 .changeset/25019-sqlite-file-failure-family.md delete mode 100644 .changeset/25019-wasm-unreadable-file-refused.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/25031-blocked-unique-index-apply-limit.md delete mode 100644 .changeset/25034-delegated-caller-reads-through-leaf.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/25050-retire-turso-encryption-key.md delete mode 100644 .changeset/25053-oauth-application-type-options.md delete mode 100644 .changeset/25058-trustproxy-three-consumers.md delete mode 100644 .changeset/25059-org-delete-read-fault-refuses.md delete mode 100644 .changeset/25060-signup-existing-address.md delete mode 100644 .changeset/25061-notification-template-html-escape.md delete mode 100644 .changeset/25062-objectql-write-wall-verdict.md delete mode 100644 .changeset/25062-plugin-security-write-wall-verdict.md delete mode 100644 .changeset/25063-delegated-organization-membership.md delete mode 100644 .changeset/25065-email-service-is-configured.md delete mode 100644 .changeset/25071-composite-title-stored.md delete mode 100644 .changeset/25073-autonumber-stored-expression.md delete mode 100644 .changeset/25077-rest-docs-viewer-attr-escape.md delete mode 100644 .changeset/25078-csv-text-cell.md delete mode 100644 .changeset/25079-federated-report-policy-denied.md delete mode 100644 .changeset/25082-notification-template-hole-placement.md delete mode 100644 .changeset/25083-cloud-managed-doors.md delete mode 100644 .changeset/25084-sqlite-refused-connect-release.md delete mode 100644 .changeset/25092-cbp-master-writable.md delete mode 100644 .changeset/25095-cbp-unwritable-master-write.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/25104-rest-docs-viewer-retired.md delete mode 100644 .changeset/25106-read-scope-delegated-organization-membership.md delete mode 100644 .changeset/25107-delegation-probes-organization-membership.md delete mode 100644 .changeset/25111-sso-domain-verification-instructed-record.md delete mode 100644 .changeset/25112-magic-link-ttl.md delete mode 100644 .changeset/25118-dcr-infer-native.md delete mode 100644 .changeset/25119-cloud-connection-one-rule.md delete mode 100644 .changeset/25120-mcp-package-objects.md delete mode 100644 .changeset/25121-spec-openapi-viewer-prose.md delete mode 100644 .changeset/25123-scaffold-peer-ledger-retired.md delete mode 100644 .changeset/25128-tenant-column-never-null-comments.md delete mode 100644 .changeset/25129-composite-title-stored-field.md delete mode 100644 .changeset/25133-refuse-orgless-production-boot.md delete mode 100644 .changeset/25134-migrate-plan-nullable-tenant-columns.md delete mode 100644 .changeset/25138-mcp-stdio-package-objects.md delete mode 100644 .changeset/25139-mcp-actions-merged-object-view.md delete mode 100644 .changeset/25144-cbp-unservable-master-boot-refusal.md delete mode 100644 .changeset/25158-refuse-plugin-auth-load-failure.md delete mode 100644 .changeset/25159-api-docs-viewer-keys-retired.md delete mode 100644 .changeset/25165-scaffold-vitest-advisory.md delete mode 100644 .changeset/25168-external-binding-managed-datasource-refused.md delete mode 100644 .changeset/25174-rest-uuid-advisory.md delete mode 100644 .changeset/25175-create-plugin-scaffold-test.md delete mode 100644 .changeset/25175-plugin-scaffold-test.md delete mode 100644 .changeset/25187-declared-rule-reader-contract.md delete mode 100644 .changeset/25189-email-template-hole-placement.md delete mode 100644 .changeset/25190-retire-body-taking-render-exports.md delete mode 100644 .changeset/25194-delivery-title-names-recipient.md delete mode 100644 .changeset/25201-platform-object-tenancy-ledger-retired.md delete mode 100644 .changeset/25202-settings-no-organization-less-rung.md delete mode 100644 .changeset/25203-adr-0131-small-carriers.md delete mode 100644 .changeset/25204-claim-orphan-org-rows-retired.md delete mode 100644 .changeset/25206-walled-self-registration-grant-sole-membership.md delete mode 100644 .changeset/25208-default-organization-one-definition.md delete mode 100644 .changeset/25211-external-binding-save-doors.md delete mode 100644 .changeset/25229-page-window-refused.md delete mode 100644 .changeset/25230-engine-declares-wall-exemption.md delete mode 100644 .changeset/25230-tenant-audit-insert-carries-organization.md delete mode 100644 .changeset/25232-artifact-package-source.md delete mode 100644 .changeset/25245-mcp-stdio-object-resources-merged.md delete mode 100644 .changeset/25248-localization-fallback-organization-scope.md delete mode 100644 .changeset/25249-settings-self-bound-organization.md delete mode 100644 .changeset/25261-jit-idp-locale-claim.md delete mode 100644 .changeset/25264-console-branded-head.md delete mode 100644 .changeset/25267-cel-has-indexed-receiver.md delete mode 100644 .changeset/25269-page-window-declared.md delete mode 100644 .changeset/25271-single-reslug-default-anchor.md delete mode 100644 .changeset/25278-restore-doors-save-refusal-family.md delete mode 100644 .changeset/25279-stored-external-binding-known-datasources.md delete mode 100644 .changeset/25281-visibility-has-argument-refused.md delete mode 100644 .changeset/25287-hide-2fa-display-when-off.md delete mode 100644 .changeset/25292-cloud-managed-hidden-doors.md delete mode 100644 .changeset/25292-features-cloud-managed.md delete mode 100644 .changeset/25292-plugin-auth-serves-cloud-managed.md delete mode 100644 .changeset/25294-is-cloud-managed.md delete mode 100644 .changeset/25296-rest-actions-disabled-package.md delete mode 100644 .changeset/25297-mcp-standalone-actions-merged-listing.md delete mode 100644 .changeset/25302-get-record-limit-positive-integer.md delete mode 100644 .changeset/25308-rest-global-actions-disabled-package.md delete mode 100644 .changeset/25315-core-security-single-definitions.md delete mode 100644 .changeset/25325-execution-status-missed.md delete mode 100644 .changeset/25330-null-label-carry.md delete mode 100644 .changeset/25331-admin-field-help.md delete mode 100644 .changeset/25332-setup-option-labels.md delete mode 100644 .changeset/25333-position-list-holders-and-grants.md delete mode 100644 .changeset/25338-create-user-permission-set.md delete mode 100644 .changeset/25339-create-key-expiry.md delete mode 100644 .changeset/25343-session-heartbeat-default-config.md delete mode 100644 .changeset/25343-session-list-columns.md delete mode 100644 .changeset/25364-last-admin-guard-read-fault-code.md delete mode 100644 .changeset/25391-dataset-dimension-null-label.md delete mode 100644 .changeset/console-0d12e95361b2.md delete mode 100644 .changeset/console-56cf4d8e89b7.md delete mode 100644 .changeset/console-5fef032b1ada.md delete mode 100644 .changeset/console-cd14d8eeffe1.md delete mode 100644 .changeset/console-cf153eb2e535.md delete mode 100644 .changeset/setup-nav-distinct-organization-labels.md diff --git a/.changeset/24446-u1-access-upgrade-report.md b/.changeset/24446-u1-access-upgrade-report.md deleted file mode 100644 index bf15509efad..00000000000 --- a/.changeset/24446-u1-access-upgrade-report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/plugin-sharing': patch ---- - -feat: at boot, an administrator is told when a package upgrade renamed, removed or collided with an access item the customer switched off, cloned or authored - -Clause-②: yes (widening: plugin-sharing's exported `SharingRuleService` gains `listDeclaredRules()`; the report itself refuses nothing and adds no key, code or setting) -Metadata-protocol: none - -ADR-0126 §6 wall 3 says no upgrade un-makes a customer's choice. A switch-off, a clone or a sharing rule row is kept by name, so a vendor rename made a switched-off item active again under its new name, and nothing told anyone. At `kernel:bootstrapped`, beside the catalog reference report, `SecurityPlugin` now compares the customer's access state with the registry this boot registered. It covers sharing rules, permission sets, positions and capabilities, and lists: - -- **Orphans:** activation-ledger rows (`position`, `permission`, `sharing_rule`) whose name the registry no longer resolves. -- **Suspected re-enablements:** a switched-off orphan whose recorded `package_id` is P, with every name of the same type P now ships that no ledger row covers. A permission-set candidate names the positions that bind it. -- **Sharing-rule collisions, per organization:** a `sys_sharing_rule` row holding a declared rule's name, so the declared rule is served nowhere in that scope. A position, permission-set or capability collision is already refused at registration or at boot. -- **Broken references in the customer's items:** a permission set authored in this system naming an object, field or capability the registry lacks, and a `sys_sharing_rule` row naming an object or criteria field the registry lacks. -- **Field denies a customer set lacks:** a package-shipped set that denies a field, beside a set authored in this system that grants the same object with no entry for that field. An unlisted field is visible. - -The findings are logged once per category, as now. They are also raised to the people who can act on them: - -- **Under `single`,** through the existing notification ingress (`messaging.emit()`, topic `security.access_upgrade`, severity `warning`), which writes a Notification Events row in Setup and the in-app inbox. It reaches the Default Organization's `admin_full_access` holders and any `OS_PLATFORM_OWNER_EMAIL` address. - - The `dedupKey` is the finding set's digest, so an unchanged set notifies once, not at every boot. - - With no messaging service, nobody to tell, or an emit the ingress refuses, one log line says the findings reached no administrator and names the fix for that cause. - - The notification is not mandatory: the ingress has no per-emit flag, so a recipient's mute hides it. To make it mute-proof, add `security.access_upgrade` to the messaging plugin's existing `mandatoryTopics` option. -- **Under `group` or `isolated`,** to the platform operator alone, by one `error` line addressed to the operator. The ledger and the catalog are deployment-level, so only the operator can act. The notification family is organization-scoped, and a walled write with no organization is refused (ADR-0131 D9); stamping an organization would hand the finding to a tenant. So no notification is attempted, and no organization is told. - -Two limits follow from ADR-0126 §9, under which a clone records no base, and from the bare `(metadata_type, name)` ledger key: - -- A re-enablement is **suspected** and may list several same-package candidates. Nothing records which name replaced which. -- The field-deny check reads "grants the same object", not "copied from that set", so it may name an unrelated set. It is a review item. - -"The old name carrying a clone" is carried by the switch-off half. The sanctioned ceremony is clone plus switch-off, and a clone whose base stays on is not a choice a rename un-makes. A read that fails stops the report for that boot, and nothing is judged from an unread store. - -- **`@objectstack/plugin-sharing`:** `SharingRuleService.listDeclaredRules()` answers every declared rule with its `packageId`, or `null` when the service reads no declarations. diff --git a/.changeset/24446-u2-marketplace-access-upgrade-refusal.md b/.changeset/24446-u2-marketplace-access-upgrade-refusal.md deleted file mode 100644 index c2cac867612..00000000000 --- a/.changeset/24446-u2-marketplace-access-upgrade-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/cloud-connection': patch -'@objectstack/plugin-security': patch -'@objectstack/spec': patch -'@objectstack/cli': patch ---- - -feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) - -Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade ` (widening). -Metadata-protocol: none - - - -**BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. - -- **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. -- **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. -- **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. -- **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": ""` (CLI: `os package install --confirm-access-upgrade `). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. -- **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). -- **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. -- **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. - -The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. 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 61be475293b..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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. -- 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-plain-organization-key-part.md b/.changeset/24583-plain-organization-key-part.md deleted file mode 100644 index 7201687ede9..00000000000 --- a/.changeset/24583-plain-organization-key-part.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/driver-sql': minor -'@objectstack/driver-turso': minor -'@objectstack/driver-memory': minor -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor -'@objectstack/cli': minor ---- - -feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database - -Clause-②: yes (widening) -Metadata-protocol: semantic - -18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). - -- **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). -- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. -- **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. -- **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. 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/24837-managed-datasource-sync-refused.md b/.changeset/24837-managed-datasource-sync-refused.md deleted file mode 100644 index ebd1b7921e7..00000000000 --- a/.changeset/24837-managed-datasource-sync-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-datasource': patch ---- - -fix(service-datasource)!: Sync (remote tables, object draft, import, catalog refresh) refuses a managed datasource with the existing `400`, because ADR-0015 §4.2 reserves federation for a datasource whose `schemaMode` is not `managed` - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (four REST doors' accept set narrows). ADR-0015 §1 puts a federated object on a datasource whose `schemaMode` is not `managed`, and §4.2 allows `object.external` only there; `managed` is the spec's default. The Sync doors refused only the platform's primary datasource (`default`). Every other managed datasource answered, including the runtime datasources an administrator creates in Setup → Datasources, which are saved with no `schemaMode` and so are managed: their tables were listed, drafted and imported as federated objects. The engine's write gate (ADR-0015 §5.3) gates only non-managed datasources, so nothing stood behind such an import. One guard in the federation service now refuses before anything is introspected, and every door that reaches the service inherits it. - -- **FROM:** on a datasource other than `default` whose `schemaMode` is `managed`, declared or by default, `GET /api/v1/datasources/:name/remote-tables` and `GET /api/v1/datasources/:name/external/tables` answered `200` with its tables; `POST /api/v1/datasources/:name/object-draft` and `POST /api/v1/datasources/:name/external/tables/:remote/draft` answered `200` with a federated object draft bound to it; `POST /api/v1/datasources/:name/external/tables/:remote/import` answered `201` and saved that object; `POST /api/v1/datasources/:name/external/refresh-catalog` answered `200` and stored an `external_catalog` for it. A name with no datasource definition was introspected whenever a driver was registered under it. -- **TO:** each of those requests answers `400` with the code its door already serves for a refusal, `EXTERNAL_DATASOURCE_ERROR` (the import: `EXTERNAL_IMPORT_ERROR`), and a message naming the rule and the remedy. Nothing is introspected, drafted, saved or stored. A name with no datasource definition gets the same refusal: nothing declares it a federation source. -- **Unchanged:** `default` keeps its own refusal and message. A datasource declared `schemaMode: 'external'` or `'validate-only'` lists, drafts, imports and refreshes as before. Test (`POST /api/v1/datasources/:name/test`) and validation (`POST /api/v1/datasources/:name/external/validate`) answer as before; validation already skipped a managed datasource, and it now asks the same predicate the guard asks. No status code or error code is added. -- **The fix for an integrator:** sync from a datasource the platform does not own. Declare it with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings; for a runtime datasource, send both in `PATCH /api/v1/datasources/:name`. A managed datasource's tables are the platform's own objects: define objects on it directly instead of importing them. 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/24883-last-admin-guard-default-organization-anchor.md b/.changeset/24883-last-admin-guard-default-organization-anchor.md deleted file mode 100644 index 0d66a41113f..00000000000 --- a/.changeset/24883-last-admin-guard-default-organization-anchor.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way. - -- **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before. - - The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded. - - It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator. - - Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there). -- **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before. - -**Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified. - -**What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization. diff --git a/.changeset/24895-preference-user-reference.md b/.changeset/24895-preference-user-reference.md deleted file mode 100644 index de9ff1be3dd..00000000000 --- a/.changeset/24895-preference-user-reference.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-messaging': patch -'@objectstack/metadata-protocol': patch ---- - -fix(service-messaging,metadata-protocol)!: a notification preference's User is a `sys_user` reference, so the record title names the person and the write door refuses an id no user holds; the default for every user is an empty User instead of `'*'` - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** (a platform object's write accept set narrows, and one stored value is rewritten). It changes what a data writer can store in `sys_notification_preference`, not what a metadata author can write, so it ships as `patch` with this banner. - -- **FROM:** `sys_notification_preference.user_id` was a required text column holding a user id or the literal `'*'`, which meant "the default for every user". Any string was stored: a mistyped id matched nobody, silently. The record title (Setup → Notification Preferences) read the raw id, for example `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap · urgent_task · inbox`. Two defaults for every user could be stored for one topic and channel in one organization, and which one applied was unspecified. -- **TO:** `user_id` is an optional `sys_user` lookup (ADR-0118 D1: no sentinel in a user column). An empty User is the default for every user, and a user's own row still overrides it. - - A write that names an id no user holds, `'*'` included, is refused: the REST data door answers `400` with `code: 'VALIDATION_FAILED'` and a finding on `user_id` with `code: 'reference_not_found'`. - - The title names the person: their name, or their email where the name is blank, then the topic and the channel. A row with an empty User reads `All users · TOPIC · CHANNEL`. A row whose User this reader cannot read, or that no longer exists, has no title rather than a misleading one. - - Deleting a user deletes that user's preference rows (`deleteBehavior: 'cascade'`). Under the lookup default (`set_null`) the person's own mute would have become the default for every user. - - At every boot (`kernel:ready`), the messaging service rewrites each stored `user_id = '*'` to an empty User, in every organization. A second run changes nothing. A `'*'` row whose topic and channel already have a default with an empty User is left as stored and reported once at `error`, naming the row; it is never deleted for you. - - On SQLite and PostgreSQL, `@objectstack/metadata-protocol` rebuilds the index `uniq_sys_notification_preference_a22d7d27` at `kernel:ready` as `(organization_id, COALESCE(user_id, ''), topic, channel)`, so a second default for one topic and channel in one organization is refused (`409`). If existing rows already break that rule, it refuses to migrate: the previous index stays, no row is touched, and the boot log and `os migrate duplicates` list the rows. MySQL/MariaDB and driver-memory keep the declared index, which does not constrain empty Users, and the boot log says so on MySQL. -- **Unchanged:** `topic = '*'` and `channel = '*'` are still the topic and channel wildcards. Most-specific-wins resolution, mandatory topics, quiet hours and digests behave as before. A row for a real user is read, matched and titled as before, except for the title text. The physical column is still a `varchar(255)`, so no DDL change is needed for it. -- **The fix for an operator:** nothing, normally; the boot rewrites the old `'*'` rows. If the boot log reports a `'*'` row that shares its topic and channel with a default whose User is already empty, delete the one that should not apply and restart. If it reports that the one-default-per-key index could not be built because two such defaults exist, list them with `os migrate duplicates`, delete one, and restart. A script or import that wrote `user_id: '*'` writes an empty `user_id` (omit it, or send `null`) for the default for every user. 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/24902-tenant-text-after-step-7.md b/.changeset/24902-tenant-text-after-step-7.md deleted file mode 100644 index 8f2c3788f3d..00000000000 --- a/.changeset/24902-tenant-text-after-step-7.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-sharing': patch ---- - -fix(spec,plugin-sharing): the `DataEvent.organizationId` description and the sharing-rule Organization help stop describing organization-less rows that 18.x no longer writes - -Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves) -Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes) - -- **`DataEvent.organizationId` (`@objectstack/spec/api`).** The description said every event on a `single`-posture deployment carries no organization, because nothing stamps the column there. Since 18.x the engine stamps every tenant row on a single-organization deployment with its Default Organization, so those events carry it. The key is now described as absent in two cases: a row of an object outside the wall, and every row of a composition that registers no organization object. The schema itself is unchanged (`z.string().min(1).optional()`). -- **`sys_sharing_rule.organization_id` (`@objectstack/plugin-sharing`).** The Setup help said "null = global". A stored rule always carries its organization; a rule that applies to every organization is a declared rule. The help now reads "The organization this rule belongs to.", with the translated locales updated to match. 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/24914-flow-inner-refusal.md b/.changeset/24914-flow-inner-refusal.md deleted file mode 100644 index d2ceac6dc16..00000000000 --- a/.changeset/24914-flow-inner-refusal.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/runtime': patch ---- - -fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message` - -Clause-②: yes (widening) -Metadata-protocol: none - -Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text. - -- **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text. -- **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built. -- **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union. - -**For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none. 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/24916-approval-flow-i18n-coverage.md b/.changeset/24916-approval-flow-i18n-coverage.md deleted file mode 100644 index 0791cb41f05..00000000000 --- a/.changeset/24916-approval-flow-i18n-coverage.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os lint` and `os i18n check` ask for an approval flow's label and each approval step's label (#24916) - -Clause-②: no -Metadata-protocol: none - -The approvals door names a request's flow and its current step in the reader's locale, from `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label`. The i18n extractor asked for `flows.FLOW.label` only on a flow with a `screen` node, and for no approval step at all, so `i18n/missing-flow` never listed an approval flow or its steps as untranslated, and `os i18n extract` never scaffolded those keys. An app's approvals inbox could show the authoring language in every other locale under a green coverage run. - -Now: - -- `flows.FLOW.label` is asked for on a flow with a `screen` node or an `approval` node, the two surfaces that show it. A flow with neither, such as a record-triggered or scheduled flow that only does work, still owes no label, because nothing shows it. -- `flows.FLOW.approvals.NODE.label` is asked for on every `approval` node that has an `id`, seeded with the node's own `label`. -- Both are reported as `i18n/missing-flow` by `os lint`, listed by `os i18n check`, and written into the skeleton by `os i18n extract`. `os i18n check --help` names approval steps among the surfaces it covers. - -**What you may see.** An app that ships an approval flow and declares a locale beyond its default now gets an `i18n/missing-flow` finding for each untranslated approval flow and step. Under `os lint --i18n-strict` or `os i18n check --strict` these are errors, like every other missing translation. To clear them, translate the keys in that locale's pack, for example: - -```ts -flows: { - expense_approval: { - label: '报销审批', - approvals: { manager_review: { label: '经理审核' } }, - }, -}, -``` - -`os i18n extract` writes the empty slots for you and keeps the strings a pack already translates. No key, schema or export changes: both keys were already part of the translation contract, and `os validate` already accepted them. 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/24932-cbp-leaf-switch.md b/.changeset/24932-cbp-leaf-switch.md deleted file mode 100644 index 131a44c6998..00000000000 --- a/.changeset/24932-cbp-leaf-switch.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/service-analytics': patch -'@objectstack/driver-turso': patch -'@objectstack/spec': patch ---- - -fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit - -Clause-②: no -Metadata-protocol: none - -- **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before. -- **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face. -- **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500. -- **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves. 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/24952-user-field-reference-refused.md b/.changeset/24952-user-field-reference-refused.md deleted file mode 100644 index 80ba9f36aa3..00000000000 --- a/.changeset/24952-user-field-reference-refused.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -feat(spec)!: a `user` field whose `reference` names an object other than `sys_user` is refused at parse, and every `user` field resolves to `sys_user` - -Clause-②: no (narrowing) -Metadata-protocol: narrowed - - - -**BREAKING** (the accept set of `FieldSchema` narrows), shipped as `minor` under the repo's launch-window convention for breaking changes. - -- **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, and `referenceTargetOf` resolved it to `probe_account`. Readers that follow the target (`$expand`, imports, seed loading, field paths) treated the field as a link to `probe_account`, while readers that branch on the type (the person picker, a `current_user` default, a sharing rule over the users a field holds) treated its values as users. -- **FROM:** a `user` field with `reference` naming any object but `sys_user` parsed, and resolved to the named object. -- **TO:** `FieldSchema` refuses it with an issue at `reference` that names the replacement, so `ObjectSchema`, `ObjectExtensionSchema`, `defineStack`, `os validate` and the metadata save door refuse it too. `referenceTargetOf` answers `sys_user` for every `user` field, whatever `reference` says, so a stored body saved before this release resolves to `sys_user` as well. `Field.user()`'s config types `reference` as `'sys_user'` and writes `sys_user` after the config spread. -- **Unchanged:** a `user` field without `reference`, with `reference: 'sys_user'` or with a blank `reference`, and `Field.user()`'s output for every typed call. -- **Fix:** change such a field to a `lookup` on the object it names, `{ type: 'lookup', reference: 'TARGET' }` (`Field.lookup('TARGET', …)`), and choose its `deleteBehavior` on purpose: a `lookup` defaults to `'set_null'` when the target record is deleted, which a `user` field never did. A `current_user` default is not legal on a `lookup` to an object other than `sys_user`. If a person was meant, drop `reference`. -- **Why no automatic rewrite.** No ADR-0087 conversion can carry the change losslessly: a `lookup` joins the referential delete walk and `deleteBehavior` has no value that means "leave it". `objectstack migrate meta` reports the semantic entry `user-field-foreign-reference-refused` instead. - -**Supersedes the `user-field-foreign-reference` warning entry in this release.** That entry announced a `@objectstack/lint` advisory `user-field-foreign-reference`, the `USER_FIELD_FOREIGN_REFERENCE` export and its `os explain` entry, and said such a field would still parse. All three are withdrawn before release and never ship: a foreign `reference` on a `user` field is now refused by `FieldSchema` instead, so there is nothing left for the advisory to warn about. - -- **For `@objectstack/lint`:** no `user-field-foreign-reference` rule, no `USER_FIELD_FOREIGN_REFERENCE` export and no `RULE_EXPLANATIONS` entry for it. `OBJECT_REFERENCE_UNKNOWN` and every other export are unchanged. The refusal arrives as a `FieldSchema` issue at `reference`, not as a lint advisory. 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/24956-boot-drift-line-names-ceremony.md b/.changeset/24956-boot-drift-line-names-ceremony.md deleted file mode 100644 index 038cd0df211..00000000000 --- a/.changeset/24956-boot-drift-line-names-ceremony.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): on a database the organization-ownership ceremony has not certified, the boot's drift line for an orphaned `organization_id` column names the ceremony instead of `os migrate apply --allow-destructive` - -Clause-②: no (operator log line text only) -Metadata-protocol: none - -A 17.x database booted on 18.x printed one `[schema-drift]` line per `organization_id` column ending `— "os migrate apply --allow-destructive" to drop it.`, during `os serve` and `os migrate apply --yes`. That command is refused on such a database: the drop is the ceremony's (ADR-0131 D10 item 5). The boot now asks the driver's own reader, `isTenantKeyCertified()`, once per table that has such a drop. When the answer is "not certified", the line keeps the finding and replaces that sentence with `— a drop the organization-ownership ceremony owns, and it has not verified on this database: run "os migrate organization-ownership", which drops it itself.` These are the words `os migrate plan` shows under the same entry. - -A certified database prints the same line as before. So does every other column and every other drift kind. Nothing the boot applies or skips changes. diff --git a/.changeset/24957-validation-rule-parent-error.md b/.changeset/24957-validation-rule-parent-error.md deleted file mode 100644 index 2064cad8e1e..00000000000 --- a/.changeset/24957-validation-rule-parent-error.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24957) - -Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent`, which the server's rule evaluator never binds) -Metadata-protocol: narrowed - -A `validations[]` rule's predicates are 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`. The server evaluates them 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 reads `parent`, such as `parent.status == 'paid'`, faults (`Unknown variable: parent`) on every write it judges, and the server refuses that write as unevaluable, the seed's own rows at boot included. - -The build's expression rule (`validateStackExpressions`) reported such a predicate at `warning` (#24720). This entry supersedes that entry's sentence "Nothing new is refused in 18.x" and its plan to refuse in 19.0: the refusal ships now, in this release, and this entry is the current statement. The rule now refuses such a predicate at `error`, at the same location (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label), with the same rule id (`expression-invalid`). The message no longer says it is a warning. The object save door runs the same pass, so its verdict is the build's finding. - -**BREAKING — what moves for consumers.** - -- `os build`, `os validate` and `os lint` fail on a `validations[]` rule whose predicate reads `parent`. The rule is judged as written, so this also covers a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true. -- An object write in publish mode that carries such a rule answered 200, with the finding among its advisories. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). - -**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. To refuse a whole write on the header's state (for example a new line under a paid invoice), use a lifecycle hook (`beforeInsert` / `beforeUpdate` / `beforeDelete`) that reads the header. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`, and such a rule already refused every write it judged. If the runtime comes to bind `parent` there, this refusal is withdrawn in that same change. -- `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, and so is a `record` column spelled like the root (`record.parent_code`). -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- No example, template or package object in this repository authors `parent` in a `validations[]` rule. -- No public export or signature moves. - - 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/24987-legal-name-help.md b/.changeset/24987-legal-name-help.md deleted file mode 100644 index 456f52c3eb1..00000000000 --- a/.changeset/24987-legal-name-help.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -fix(service-settings): Setup → Company → Legal name help no longer points to an organization name under Branding - -Clause-②: no - -Metadata-protocol: none - -The `legal_name` help in the Company settings page (the manifest description and the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles) said the legal name "may differ from the organization name set under Branding". Branding's workspace name is the product name, not an organization name, and the Branding page is not offered at all under the `group` and `isolated` postures, where Company is. The help now reads "Registered legal name of the organization, as it appears on contracts and invoices." in every locale. Copy only; no key, value or behaviour changes. 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/24993-contributed-nav-i18n-coverage.md b/.changeset/24993-contributed-nav-i18n-coverage.md deleted file mode 100644 index 6ecbbd6d744..00000000000 --- a/.changeset/24993-contributed-nav-i18n-coverage.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os lint` and `os i18n check` ask for the label of a navigation item another package contributes into an app (#24993) - -Clause-②: no -Metadata-protocol: none - -A package can add items to an app it does not own through `manifest.navigationContributions`. The runtime merges those items into the app's navigation and shows each one's label from `apps.APP.navigation.ID.label`, where `APP` is the app the item is contributed to. `os validate` already accepted that key. But the i18n extractor only read each app's own `navigation`, so a contributed item's key was never expected: deleting its translation left `os lint --strict` and `os i18n check` green. The same deletion on an item the app declares itself was reported. - -Now a contributed item is expected under its target app, from either place a contribution is declared: the stack's own `manifest`, or a package's manifest inside a `composeStacks(…, { manifest: 'preserve' })` artifact. Its source text is its own `label`. A contributed group's children are expected as an app's own group children are. An item the app also declares keeps the app's entry, and a contribution read from two places is expected once. - -**What you may see.** A project that contributes navigation and declares a locale beyond its default now gets an `i18n/missing-navigation` finding for each contributed item that locale's pack does not translate. It is a warning by default, which `os lint --strict` fails on. Under `os lint --i18n-strict` or `os i18n check --strict` it is an error, like every other missing translation. To clear them, translate the key under the target app in that locale's pack, for example: - -```ts -apps: { - crm_enterprise: { - navigation: { nav_case: { label: '工单' } }, - }, -}, -``` - -`os i18n extract` now writes these slots too, and keeps the strings a pack already translates. No key, schema or export changes: the key was already part of the translation contract. 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/25015-digest-heading-locale.md b/.changeset/25015-digest-heading-locale.md deleted file mode 100644 index eb5ca7a7914..00000000000 --- a/.changeset/25015-digest-heading-locale.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-messaging': patch ---- - -fix(service-messaging): the heading of a notification digest of two or more items is rendered in its recipient's language, not as the English "You have N notifications" for everyone - -Clause-②: no -Metadata-protocol: none - -- **Before.** A recipient whose notification preference digests `daily` or `weekly` received each window's digest headed "You have N notifications" in English, whatever their language, even when every item under it was rendered in their language. -- **Now.** The heading is keyed (`messages.notificationDigestTitle`, with a `{{count}}` hole) in `@objectstack/service-messaging`'s own translation bundle, in en, zh-CN, ja-JP and es-ES. It is rendered at delivery by the same keyed-text renderer as the digest's items (`EmitInput.keyedText`), in the same locale read: the recipient's `sys_user.locale`, then the deployment default. A zh-CN recipient reads "您有 3 条通知". -- **Unchanged.** The English sentence stays the literal fallback, byte for byte: with no `i18n` service, with no renderer composed, or when neither the recipient's language nor the deployment default has a pack. A one-item digest is still titled by its item. No export, option or type changes. -- **Cost.** With an `i18n` service registered, each digest of two or more items now reads its recipient's locale once, even when none of its items is keyed, because its heading always is. 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/25019-wasm-unreadable-file-refused.md b/.changeset/25019-wasm-unreadable-file-refused.md deleted file mode 100644 index a6aae47e083..00000000000 --- a/.changeset/25019-wasm-unreadable-file-refused.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/driver-sqlite-wasm': patch -'@objectstack/service-datasource': patch ---- - -fix(driver-sqlite-wasm)!: a file wasm SQLite cannot read is refused at connect, naming the file, and left as it was found, instead of being renamed and replaced with an empty database (#25019) - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (wasm SQLite's connect accept set narrows). `WasmSqliteConnection.open()` used to answer a file sql.js could not read by renaming it to `.corrupt-` and starting an empty database in its place, with one warning. That self-heal was written for wasm SQLite's own torn writes, which the atomic flush has since closed; what still reached it was an operator's file on a declared datasource. A datasource declared `driver: 'sqlite-wasm'` over such a file read `available` over the empty replacement, in development and production alike, and a declared `sqlite` datasource on a development boot whose native addon cannot load stepped down to wasm SQLite and met the same rename. The native driver already refuses the same files at connect. - -- **FROM:** a file sql.js cannot read (plain text, another format, a database truncated inside its header or short of the pages its schema names) was moved to `.corrupt-`; `connect()` resolved over an empty database, and the datasource read `available`. On a development boot whose native addon cannot load, `resolveSqliteDriver` (`@objectstack/service-datasource`) returned that wasm driver, and when wasm SQLite failed for any reason it went on to the in-memory driver. -- **TO:** `SqliteWasmDriver.connect()` rejects with the native driver's `SqliteDatabaseFileError`: `code: 'SQLITE_NOTADB'` for bytes that are not a database, `code: 'SQLITE_CORRUPT'` for a database it cannot read, `filename`, and a message naming the file and how to move it aside. Nothing is renamed, nothing is written beside it, and a stale write-ahead log beside a refused file stays where it is. `WasmSqliteConnection.open()` rejects with the same code and `filename`. `resolveSqliteDriver` re-throws that refusal at the wasm rung instead of trying the in-memory driver, so the datasource's connect fails and its connection state says so. Any other wasm failure, sql.js itself not loading first among them, still goes on to the in-memory driver. -- **Unchanged:** a real database opens and answers; an absent file is created; an empty file opens as an empty database; a native addon that cannot load over a real or absent file still steps down to wasm SQLite. -- **The fix for an operator:** restore the database from a backup, or move the unreadable file aside yourself (`mv '' '.unreadable'`) and start again: an absent file is created empty on the next connect. A development server whose own wasm image is damaged no longer starts itself clean; this is that step, done by hand. 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/25031-blocked-unique-index-apply-limit.md b/.changeset/25031-blocked-unique-index-apply-limit.md deleted file mode 100644 index 24dbcbb4dd0..00000000000 --- a/.changeset/25031-blocked-unique-index-apply-limit.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/driver-sql': patch -'@objectstack/cli': patch ---- - -fix(driver-sql,cli): `os migrate plan` / `apply` no longer offer `--allow-destructive` for a unique index the existing rows already violate - -Clause-②: yes (widening) -Metadata-protocol: none - -The duplicate pre-flight that blocks such a `create_index` (ADR-0120 D4) said so only in the entry's message, and the apply-limit seam the two commands read asked the driver by op type. So `os migrate plan` closed with `(add --allow-destructive for drops / tightenings)`, `os migrate apply --yes` said `re-run with --allow-destructive to include them`, and that re-run refused the entry and ended PARTIAL. - -- **`ManagedDriftEntry.applyLimit` (`@objectstack/driver-sql`, new and optional).** Why the reporting driver's apply door will not perform this entry on this database, read from its data. The duplicate pre-flight sets it on a blocked unique index, naming the conflicting groups. It is absent on every other entry, so a reader that ignores it sees the report as before. On `--json`, a blocked entry now carries this key. -- **`os migrate plan` / `os migrate apply` (`@objectstack/cli`).** The apply-limit seam reads the entry's own limit first, then the driver's per-op one. A blocked unique index is listed with the conflicting groups as the reason it cannot be applied, the footer and the skip lines stop offering `--allow-destructive` for it, and it is counted among the changes that remain drift. A database with no blocked index prints as before. - -Unchanged: `os migrate apply --allow-destructive` still names such an entry as not applied and reports the run PARTIAL with exit 1, because the database still differs from its metadata. To clear it, deduplicate the listed rows and run `os migrate plan` again. diff --git a/.changeset/25034-delegated-caller-reads-through-leaf.md b/.changeset/25034-delegated-caller-reads-through-leaf.md deleted file mode 100644 index 804059c0377..00000000000 --- a/.changeset/25034-delegated-caller-reads-through-leaf.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-audit': patch -'@objectstack/service-storage': patch ---- - -fix(plugin-audit,service-storage): a delegated (on-behalf-of) caller reads the parent-record gates through the ReadableIds leaf, no longer through the bounded 2000-row probe (ADR-0139 D5.1, as amended) - -Clause-②: no -Metadata-protocol: none - -The activity, ledger and comment-thread read gates (`@objectstack/plugin-audit`) and the attachment read gate (`@objectstack/service-storage`) routed every read by a delegated caller to the whole-read probe: a SYSTEM pre-scan of at most 2000 rows, then one caller-scoped read per parent object. Past 2000 candidate rows, a delegated caller was served a truncated list (fail-closed, never a leak) with a matching `total`. - -Since the engine composes a `ReadableIds` leaf for a delegated caller from the delegator intersection (ADR-0090 D10, #25009), the gates now write such a caller the same leaves as any caller. It is served every row its delegator intersection admits, with a true `total`. This is the same set the probe's own readability answer admits, without the window. ADR-0139 D5's two remaining classes are unchanged: a parent narrowed by an object-scoped read middleware, and a parent bound to another datasource. Both still take the bounded probe, read with the caller's delegated context. The gates no longer read the delegation link themselves, and the `delegated-caller` value is gone from the gates' internal whole-read probe reasons. Neither is exported. 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/25050-retire-turso-encryption-key.md b/.changeset/25050-retire-turso-encryption-key.md deleted file mode 100644 index 6ee29fb2065..00000000000 --- a/.changeset/25050-retire-turso-encryption-key.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-turso': minor -'@objectstack/service-datasource': minor ---- - -feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource - -Clause-②: no (narrowing) - -Metadata-protocol: narrowed - - - -`encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed. - -**BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). - -- **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them. -- **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words. -- **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence. - -### Migration: FROM → TO - -| You wrote | Write instead | -| --- | --- | -| `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server | -| `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | -| `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest | - -The fix in one line: delete `encryptionKey` from every turso datasource. - -### The ADR-0087 disposition - -- **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value. -- A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand. -- The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it. -- `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`. - -### Stored rows stay redacted - -A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike. - -### Blast radius - -Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero. 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/25059-org-delete-read-fault-refuses.md b/.changeset/25059-org-delete-read-fault-refuses.md deleted file mode 100644 index 1559514a36b..00000000000 --- a/.changeset/25059-org-delete-read-fault-refuses.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): an organization delete whose admission cannot read what it decides on is refused with 503 `SERVICE_UNAVAILABLE`, before better-auth's first write - -Clause-②: no -Metadata-protocol: none - -The refusal replaces an outcome only a failed store read produces, and the typed 503 replaces a bare 500 fault; no request a caller can make is answered differently. - -The `POST /organization/delete` admission counts the records an organization still owns before better-auth's handler runs, so a refused delete writes nothing on any driver. - -- **A read the admission decides on that fails now refuses.** When its read of the caller's session, of the caller's membership or of the organization row failed, the admission handed the request to better-auth, which could then clear the caller's active organization and delete the members and invitations with nothing counted. On a driver without transactions those deletes stayed while the organization row survived. The admission now answers `503` with the standard `SERVICE_UNAVAILABLE` code, before better-auth's first write. The body names the table it could not read; the driver's own message is not relayed. A failed session read used to answer the same as no session at all; the two are now told apart, and only no session still goes to better-auth's `401`. -- **A failed Default Organization decision answers the same `503`.** It already refused before any write, but as a bare error, which better-auth answers with an untyped `500`. -- **Unchanged.** What better-auth answers itself still goes to it: no session, no membership, no permission, no organization row, and a member row whose role the vendor's own permission check cannot evaluate (its identical check runs before its first write). The `409 DELETE_RESTRICTED` refusal and the Default Organization's `403` are unchanged. - -**What a client does.** Treat `503 SERVICE_UNAVAILABLE` from an organization delete as "retry later": nothing was deleted. diff --git a/.changeset/25060-signup-existing-address.md b/.changeset/25060-signup-existing-address.md deleted file mode 100644 index 29a6b2817fe..00000000000 --- a/.changeset/25060-signup-existing-address.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/plugin-email': patch ---- - -feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered - -Clause-②: yes -Metadata-protocol: none - -The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered. - -- **When the explicit refusal stays, unchanged.** - - The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered. - - The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused. - - The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account. -- **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart. -- **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32. -- **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder. -- **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step. -- **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap. -- **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail. 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/25062-objectql-write-wall-verdict.md b/.changeset/25062-objectql-write-wall-verdict.md deleted file mode 100644 index a4e40e9df01..00000000000 --- a/.changeset/25062-objectql-write-wall-verdict.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the write preview (`validate()`, the import dry run) answers a row's `organization_id` from the Layer 0 tenant wall's own verdict - -Clause-②: yes (widening) - -Metadata-protocol: none - -`validate()` runs no middleware, so the tenant write wall never judged a preview row. On a walled deployment (`group` or `isolated`) the preview reported a caller's `organization_id` as dropped where the write stores it as sent, and answered valid a row naming an organization the write refuses with 403. - -- **New, optional: `ObjectQL.registerWriteWallVerdict(fn)`**, beside `registerWriteGateProbe`, with its types `WriteWallVerdict` and `WriteWallRowVerdict`. The layer that owns the wall registers it (`@objectstack/plugin-security` does), and the engine never decides the verdict itself. -- **`validate()` reads each row's verdict.** A row whose organization the wall judges keeps `organization_id` through the static `readonly` strip, so no `droppedFields` entry names it, as on the write. A row the wall refuses answers `valid: false` with the write's own code (`PERMISSION_DENIED`) and message, under `organization_id` for a refused value and `_record` for the caller's state, and it drops nothing. Rows stay independent, and the set's `valid` stays the conjunction. A row the strips changed is asked about again, as the write's stored-image half judges it. -- **Unregistered, nothing changes**: the strip runs as before, which is what the write does in a composition without the security plugin. -- **Named limit:** the preview runs no hooks, so an organization a `beforeInsert` / `beforeUpdate` hook writes is judged by the write alone. diff --git a/.changeset/25062-plugin-security-write-wall-verdict.md b/.changeset/25062-plugin-security-write-wall-verdict.md deleted file mode 100644 index e78e9f3ccb9..00000000000 --- a/.changeset/25062-plugin-security-write-wall-verdict.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): step 3.7's tenant write wall is composed by one method, and the write preview asks it about each row - -Clause-②: yes (widening) - -Metadata-protocol: none - -The engine's write preview (`validate()`, the import dry run) runs no middleware, so it never heard the Layer 0 tenant wall's verdict on a row's `organization_id`. - -- **One spelling of the wall.** Step 3.7's judgement (the no-active-organization pre-test with its delegator leg, the supplied-value forge guard, the emptied organization on update, the #24784 / #24821 no-active-organization insert arms, and whether the wall judges the organization) is composed by one method, `composeWriteTenantWall`. The middleware's payload half and its stored-image seam both call it. Behaviour, refusals and log lines are unchanged. -- **New: `SecurityPlugin.writeWallVerdict(object, operation, context, rows)`**, registered on the engine as `registerWriteWallVerdict`. It answers each row from that same method: admitted (and whether the wall judges its organization), or refused with the write's `PermissionDeniedError` code and message. It answers `null` for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached. On an engine without the seam the plugin logs one warning at start and the preview keeps its old answer. 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/25071-composite-title-stored.md b/.changeset/25071-composite-title-stored.md deleted file mode 100644 index 9f2fe58a008..00000000000 --- a/.changeset/25071-composite-title-stored.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -fix(lint): the record-title diagnostics no longer steer a composite title that must be queried into a formula field - -Clause-②: no (wording only: no verdict, severity, code, key or export moves) -Metadata-protocol: none - -The `title-format-retired` and `title-unresolvable` messages and fix lines, the `object/missing-name-field` fix line, and the `os explain title-format-retired` text now say the same thing: a composite record title that an import, a sort or a search must match is a stored computed field, a non-formula field (such as a `text` field) that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`. A `formula` field with `returnType: 'text'` stays a legal `nameField` for a title shown for display only: its value is computed on read and stored nowhere, so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell against it. - -No finding fires on a different object than before, and no severity changes. Only the text an author reads changes. diff --git a/.changeset/25073-autonumber-stored-expression.md b/.changeset/25073-autonumber-stored-expression.md deleted file mode 100644 index 940c9cd222f..00000000000 --- a/.changeset/25073-autonumber-stored-expression.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/objectql': patch -'@objectstack/driver-sql': patch -'@objectstack/driver-turso': patch -'@objectstack/plugin-pinyin-search': patch ---- - -fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number - -Clause-②: yes (widening) -Metadata-protocol: none - -A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. - -- **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. -- **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. -- **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. -- **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. -- **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. 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/25078-csv-text-cell.md b/.changeset/25078-csv-text-cell.md deleted file mode 100644 index 1cd854421c1..00000000000 --- a/.changeset/25078-csv-text-cell.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest)!: the CSV export writes a text cell its consumer would interpret in a form that consumer reads as text, and the CSV import reverses exactly that - -Clause-②: no (narrowing) — the CSV export serves different bytes for some text cells, and the CSV import parse reads a cell of the same shape differently; nothing is widened. -Metadata-protocol: none (a REST export's bytes and a REST import's parse; nothing a metadata author writes changes) - - - -**BREAKING** (the CSV export's bytes change for some text cells, and the CSV import reads a user-authored cell of the same shape differently), shipped as `patch` because the metadata protocol does not move. - -The CSV export (`GET /api/v1/data/:object/export?format=csv`) wrote every text cell exactly as stored, so a cell whose leading character the program the file is opened in interprets was read there as something other than text. The rule below is defined once, in `csvTextCellNeedsMark` (`packages/rest/src/export-format.ts`), and both directions read it. - -- **The export.** A CSV text cell whose first character is one of a small fixed set that its consumer interprets is written with one apostrophe (U+0027) in front of its content, before the usual RFC-4180 quoting. A text cell is a string in a column whose field is not declared numeric, boolean, date or datetime, a header label, or a reference field's display title; with no field metadata (the raw path) every string cell is one. A cell made only of digits, spaces and the characters a signed number or a phone number is written with is never marked, so phone numbers and signed amounts are byte-identical. -- **The import.** `parseCsvToRows` removes exactly one leading apostrophe from a cell, header row included, when the rest of the cell is one the export would mark. It serves the sync import (`POST /api/v1/data/:object/import`), the async import job (`POST /api/v1/data/:object/import/jobs`) and plugin-auth's admin user import (`POST /api/v1/auth/admin/import-users`), with an inline mapping or a named one. So a CSV exported and imported back returns the stored value, with the one exception in the next point. -- **A cell that begins with an apostrophe** followed by a cell the export would mark now imports without that apostrophe; the file cannot tell it from an exported one. That holds for a user-authored cell, and for a stored text value of that shape, which the export writes unchanged, so it comes back without its leading apostrophe. A cell beginning with an apostrophe followed by anything else imports unchanged. -- **Measured.** A value beginning with TAB or CR is marked; the import removes the mark, and the default `trimWhitespace: true` then trims the leading TAB or CR exactly as it did before. With `trimWhitespace: false` it returns exactly. -- **Unchanged.** Every other cell; numeric, boolean, date and datetime cells; the xlsx and json export formats; json and xlsx import payloads. -- **What to do.** Nothing for a file that goes back through these import doors. A program that reads the CSV export some other way and needs the stored text applies the same reversal: remove one leading apostrophe from a cell whose rest the export would mark, by the rule in this repository's `packages/rest/src/export-format.ts`. 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/25082-notification-template-hole-placement.md b/.changeset/25082-notification-template-hole-placement.md deleted file mode 100644 index 93d3611a156..00000000000 --- a/.changeset/25082-notification-template-hole-placement.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-messaging': patch ---- - -fix(service-messaging)!: `sys_notification_template` refuses an html or mjml body that places a hole where the renderer's encoding is not right for it - -An html or mjml notification template body HTML-escapes every hole's value. That encoding is right in two placements only: element text, and a quoted plain-text attribute value. A hole anywhere else (an attribute read as a URL, an event handler, a style, an attribute holding markup, a meta element attribute, an unquoted attribute value, inside a tag, a raw-text element, a comment, or SVG / MathML content) received an encoding that does not fit where it lands. Such a body is now refused when it is saved. A hole counts as supported only when every way an HTML parser can read the body places it in element text or a quoted plain-text attribute value. The check is the template object's own save hooks, judged over the stored row overlaid with the write, so a row stored before this change that breaks the rule is refused on any edit that leaves it breaking it: frozen, not bricked. It is still read, listed and deleted, and it keeps rendering and delivering as before. No stored row is rewritten. - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (a platform object's write accept set narrows, stored rows included). It ships as `patch` because the metadata protocol does not move. - -- **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row with format `html` or `mjml` and a hole outside element text and quoted plain-text attribute values was accepted and stored. A row already stored that way took every edit. -- **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding at `body` (`code: 'invalid_value'`) that names each refused hole and its placement. An import commit fails that row with `field: 'body'` and `code: 'invalid_value'`. 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 writer is judged, system context included. -- **Unchanged:** `markdown` and `text` bodies, whose holes render verbatim; html and mjml bodies whose every hole sits in element text or a quoted plain-text attribute value; reading, listing and deleting any row; and how every row renders and is delivered. No error code is added. -- **The fix for an author:** move each refused hole into element text or a quoted plain-text attribute value, or set the format to `text` or `markdown`. A link whose address comes from a value belongs in an email template (`email_template`), which declares that variable as a URL; name it in the notify step's `template`. diff --git a/.changeset/25083-cloud-managed-doors.md b/.changeset/25083-cloud-managed-doors.md deleted file mode 100644 index 080804c48e6..00000000000 --- a/.changeset/25083-cloud-managed-doors.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth)!: a cloud-managed environment (`OS_CLOUD_MANAGED=1`) refuses local self-registration, invitations, user creation and import, and sets passwords only on local accounts — self-hosted deployments are unchanged - -Clause-②: yes (narrowing) — one widening and one narrowing. The widening: the package entry now exports `cloudManagedRefusal`, `CLOUD_MANAGED_REFUSAL_CODE`, `CLOUD_MANAGED_REFUSAL_STATUS`, `CLOUD_MANAGED_REFUSAL_MESSAGES` and `CloudManagedDoor`, so a host that mounts its own twin of one of these doors (the cloud's per-environment `set-initial-password`, objectstack-ai/cloud#3169) refuses with the same status, code and message. The narrowing: under the managed mode only, doors that admitted a request now refuse it: every audience posture but `invite_only`, email sign-up, member invitations, admin create-user and import-users, an admin password set on an identity-provider account, and a first password for a caller without platform-admin standing. A process that does not set `OS_CLOUD_MANAGED=1` answers every one of these doors exactly as before. -Metadata-protocol: none - - - -**BREAKING** for a cloud-managed environment only (a process started with `OS_CLOUD_MANAGED=1`). A hosted environment's people are invited by the organisation that hosts it, in the cloud, and sign in through the cloud's identity provider; authorization (positions, permission sets) stays in the environment. The local doors that would let someone in another way now refuse, and the password doors split by population rather than switching off, so a local break-glass administrator stays reachable when the identity provider is not. Every refusal is `409` with code `RESOURCE_CONFLICT` (in the ADR-0112 envelope on the raw routes, in better-auth's own `{ code, message }` body on `invite-member`) and a message that says what was not done and where it is done instead. - -- **Audience posture.** FROM: any posture booted and patched. TO: under the managed mode every posture but `invite_only` (the default) is refused at construction and on every merged config patch, with a message naming the posture and the fix. -- **Email sign-up.** FROM: open unless `disableSignUp` or SSO-only was set. TO: forced off under the managed mode, and `getPublicConfig()` advertises `disableSignUp: true`. The bootstrap owner's first sign-up still enters, as it does under SSO-only. -- **Invitations.** FROM: `organization/invite-member` created an invitation, and with `resend: true` extended a pending one and sent its e-mail again. TO: `409` on both, refused in the route's `before` hook, ahead of better-auth's body validation, session and permission checks and handler, so no invitation row is written or extended and no e-mail leaves. -- **Admin create-user and import-users.** FROM: created or imported users. TO: `409` after the administrator check (a caller without it still gets `403`), and no user is written. -- **Admin set-user-password.** FROM: set a password on any user. TO: refuses a target whose `source` is `idp_provisioned` with `409`; a local (`env_native`) target is still answered. -- **Set-initial-password.** FROM: any signed-in user without a credential could set one. TO: only a caller with platform-admin standing may; any other signed-in caller gets `409`, and an anonymous caller still gets `401`. -- **New exports.** `cloudManagedRefusal(door)` and the constants behind it, for a host twin of one of these doors. -- **Unchanged:** every door on a process without `OS_CLOUD_MANAGED=1`, including one running SSO-only; existing users, invitations and credentials. -- **The fix for an operator:** nothing on a self-hosted deployment. On a hosted environment, invite people from the organisation in the cloud console; their account arrives on their first sign-in. Keep a local password only for the break-glass administrator. 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/25092-cbp-master-writable.md b/.changeset/25092-cbp-master-writable.md deleted file mode 100644 index 0c1387fb419..00000000000 --- a/.changeset/25092-cbp-master-writable.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': patch ---- - -feat(spec)!: a `controlled_by_parent` object whose one required `master_detail` is marked `readonly: true` or `system: true` is refused at parse - -Clause-②: no (narrowing) - -Metadata-protocol: narrowed - - - -**BREAKING** (a declaration the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage. - -ADR-0055 §1: a `controlled_by_parent` object derives its record access from exactly one required `master_detail` field. That one master must be a reference every write of a detail supplies, and a field declared `readonly: true` or `system: true` is not one an author's write supplies, so it cannot be that master. `os lint` already reported it at `error` under `relationship/master-detail-required`, but `os lint` is not a publishing door: `os validate`, `os build`, `ObjectSchema.create()`, `defineStack` and the metadata save door accepted it. - -- **FROM:** `sharingModel: 'controlled_by_parent'` with one required `master_detail` declared `readonly: true` and/or `system: true` parses, builds and saves. -- **TO:** the parse refuses it at `fields`, naming the object, the field and each flag (the message opens with "Object 'OBJECT' declares sharingModel 'controlled_by_parent' with its one required master_detail, FIELD, marked readonly: true", with each name and flag in a code span). The save door answers `422 INVALID_METADATA` with that issue and stores nothing. `ObjectSchema.create()` refuses it too, because the parse it ends in is the same rule. An objectExtensions entry that leaves the merged master flagged is refused at the extension, as before for the other shapes. -- **The fix:** remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`). -- **Unchanged:** the runtime. The flags do not change which field the runtime resolves as the master. A flag on a second, non-required `master_detail` beside the master is still reported by `os lint` alone. - -Data at rest still loads. A stored object in this shape registers under its `[metadata_spec_invalid]` diagnostic, and `os migrate meta --stored` lists it as a TODO under `cbp-master-relation-tiers-retired`. No conversion rewrites it: dropping the flag changes who may write the reference, which is the owner's decision. - -`@objectstack/spec/data`: `controlledByParentMasterRefusal` returns the new shape `master-not-writable` for this object, so a producer that asks it before writing `controlled_by_parent` gets the parse's verdict. `ControlledByParentMasterShape` gains that member. `isControlledByParentMasterField` is unchanged: a flagged required `master_detail` still counts as the master, so beside a second required one the object is still refused as having two. - -`@objectstack/lint`: the `relationship/master-detail-required` explanation (`os explain`) now says that the parse refuses every shape that leaves the object without exactly one writable required master, and that a shape on a second, non-required `master_detail` is this rule's report alone. diff --git a/.changeset/25095-cbp-unwritable-master-write.md b/.changeset/25095-cbp-unwritable-master-write.md deleted file mode 100644 index fde67374531..00000000000 --- a/.changeset/25095-cbp-unwritable-master-write.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security)!: under a `controlled_by_parent` declaration whose one master reference is `readonly` or `system`, every write that sets that reference is refused (ADR-0055 §1) - -Clause-②: no (narrowing) - -Metadata-protocol: none - - - -**BREAKING** for a caller that relied on such a write landing. A non-protocol change, shipped as `patch`. - -ADR-0055 §1 derives a `controlled_by_parent` detail's access from exactly one required `master_detail` field that every write of a detail supplies. The parse refuses that master when it is declared `readonly: true` or `system: true`, so an object with it reaches the runtime only through a door that does not parse: a raw `registerObject` or `registerApp`, a `defineStack` call with `strict: false`, or a stored object the boot registers under its `[metadata_spec_invalid]` diagnostic. On such an object the security plugin's write gate let writes that set the master reference through, and a detail could end up outside the master its access derives from, readable by nobody, while the write reported success. - -- **Before.** An insert under that declaration was written when it named a master. An update could set the reference. -- **Now.** Every write that sets the reference answers `INVALID_METADATA` (422) and writes nothing, before any master is asked: every insert, single or batch, whether or not it names a master, and every update whose body carries the reference key, whatever the value, `null` included. The message is the parse's own refusal of the declaration, prefixed with the write it refused, so it names the object, the field, each flag and the fix. It is the same for every caller the gate judges. -- **Unchanged.** - - The rows already stored: the security plugin still derives their read access from that field, so they keep their master and are read as before. - - A delete, and an update whose body leaves the reference key out. - - System contexts, every object declared with an ordinary required master, and every object that does not declare `controlled_by_parent`. - -**What to do.** Fix the declaration as the parse says: remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`). 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 869c1a8afd8..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 capability 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. - -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 and the permission its connect and disconnect requests and its connection details require, which another change in this release raises to `manage_platform_settings`. The connection status answer is otherwise 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/25104-rest-docs-viewer-retired.md b/.changeset/25104-rest-docs-viewer-retired.md deleted file mode 100644 index 5cb10f560c9..00000000000 --- a/.changeset/25104-rest-docs-viewer-retired.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -feat(rest)!: `GET {base}/docs`, the OpenAPI document's HTML viewer page, is retired; `GET {base}/openapi.json` stays - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (a published route stops being served), shipped as `patch` because it is a non-protocol change: it changes what a REST caller is served, not what a metadata author can write. - -`RestServer.registerOpenApiEndpoints` mounted two routes on every base: the OpenAPI document and an HTML viewer page for it. The viewer page is retired on both route shapes, the unscoped `{base}/docs` and the environment-scoped `{base}/environments/:environmentId/docs`. Nothing read it, and the API origin serves no HTML viewer. - -- **Not served now:** `GET {base}/docs` has no route. A request to it gets the host's ordinary not-found answer, the same one any unmounted path gets: on the standard server (`@objectstack/plugin-hono-server`) that is `404` with code `ENDPOINT_NOT_FOUND`, for a signed-in caller and an anonymous one alike. -- **Unchanged:** `GET {base}/openapi.json` on both route shapes, its `api.enableOpenApi` gate, its anonymous `401` and the document a signed-in caller receives. The document no longer lists the `/docs` path, because it describes only mounted routes. -- `api.enableOpenApi` now gates one route, the document. - -What changes for you. If you opened the page, load the document from `GET {base}/openapi.json` into an OpenAPI viewer of your choice. diff --git a/.changeset/25106-read-scope-delegated-organization-membership.md b/.changeset/25106-read-scope-delegated-organization-membership.md deleted file mode 100644 index b25bc6135e3..00000000000 --- a/.changeset/25106-read-scope-delegated-organization-membership.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security)!: the raw-SQL read scope (`getReadFilter`) answers the deny sentinel for a delegated context the operation prologue refuses — the delegator's membership must back the organization the read runs in, on a walled posture - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (the security service's read scope narrows for one context shape). ADR-0021 D-C makes the raw-SQL read scope the find path's scope, and the provider's own contract is agreement with the engine middleware. Under `isolated` or `group`, the delegated-principal prologue already refuses every operation of an on-behalf-of context whose active organization the delegator's membership does not back (ADR-0090 D10, `vetOrganizationClaim`). `getReadFilter` did not ask that question, so for the same context it answered a composed read scope where the find path refuses. It now asks it, once, beside the dangling-delegator refusal it already mirrored. - -- **FROM:** under `isolated` or `group`, `getReadFilter` for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of answered a read scope composed from the two principals' layers, where the engine's find refuses the same context. -- **TO:** it answers `RLS_DENY_FILTER` itself (the spec's sentinel, by reference), so a door that bypasses the engine reads zero rows for that context, as the engine's find refuses it. -- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator (still the deny sentinel) 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 keep their scope. -- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand and hands it to the security service or to the analytics service must set its `tenantId` to an organization the delegating user is a member of, or leave it unset. diff --git a/.changeset/25107-delegation-probes-organization-membership.md b/.changeset/25107-delegation-probes-organization-membership.md deleted file mode 100644 index f68111d470d..00000000000 --- a/.changeset/25107-delegation-probes-organization-membership.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the refusal the operation prologue makes for a delegated context whose delegator is not a member of the organization it runs in, on a walled posture - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (the security service's answers narrow for one context shape, and `ExplainEngineDeps` gains a required member). Under `isolated` or `group`, the delegated-principal prologue refuses every operation of an on-behalf-of context whose active organization the delegator's own membership does not back (ADR-0090 D10, `vetOrganizationClaim`), and the raw-SQL read scope answers that refusal too. The other readers in this package that resolve a delegator on their own did not ask that question, so for the same context they answered an admission the operation refuses. Each now asks it at the prologue's point, through one helper (`delegatedOrganizationRefusal`) that the prologue and the read scope ask as well. - -- **FROM:** under `isolated` or `group`, for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of, `canReadObject`, `canWriteObject` and `canExport` could answer `true`; `getReadableFields`, `getMetadataReadableFields`, `getWritableFields` and `getQueryableFields` answered the intersected field list; the access explanation reported the D10 intersection and could answer `allowed: true`; `describeDelegationNarrowing` could report a narrowing; and the explanation's per-record write gate asked the sharing service as the agent and the delegator. -- **TO:** the three probes answer `false`; the four field projections answer `[]`; the explanation answers `allowed: false`, its principal and object_crud layers `denies`, naming the organization the delegator is not a member of; `describeDelegationNarrowing` answers `{ narrowed: false }`, as for a dangling delegator; and the per-record write gate refuses without asking the sharing service. -- **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator, 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 keep every answer. -- **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand must set its `tenantId` to an organization the delegating user is a member of, or leave it unset. -- **`ExplainEngineDeps`:** an embedder that builds the explain engine's dependencies itself passes the deployment's tenancy posture as `tenancyPosture`, the value the security plugin's enforcement runs under. The compiler names the missing member. diff --git a/.changeset/25111-sso-domain-verification-instructed-record.md b/.changeset/25111-sso-domain-verification-instructed-record.md deleted file mode 100644 index 10f2927b433..00000000000 --- a/.changeset/25111-sso-domain-verification-instructed-record.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): SSO domain verification accepts the DNS TXT record that request-domain-verification tells the operator to publish (#25111) - -Clause-②: no — the verify door now accepts the record the platform already names, as `@better-auth/sso` designed it; the one record it stops accepting is `undefined=TOKEN`, which only the defect produced and nothing instructs. -Metadata-protocol: none - -`POST /api/v1/auth/admin/sso/request-domain-verification` tells the operator to publish a TXT record named `_better-auth-token-PROVIDER.DOMAIN` with the value `_better-auth-token-PROVIDER=TOKEN`. `POST /api/v1/auth/admin/sso/verify-domain` (and the vendor route behind it, `POST /api/v1/auth/sso/verify-domain`) refused exactly that record with `502 DOMAIN_VERIFICATION_FAILED`. The vendor builds the record it accepts from the verification row's `identifier`, and the storage adapter handed it that row without the column (`sys_verification.identifier` is `internal`), so it expected `undefined=TOKEN`. - -- **Now.** The adapter hands better-auth the row's `identifier` the same way it already hands back `value`. The instructed record verifies on both doors and marks the provider verified. -- **Still refused.** A wrong token, the token under another provider's label, the record published under another provider's name, and `undefined=TOKEN`: each answers `502 DOMAIN_VERIFICATION_FAILED` and leaves the provider unverified. -- **Unchanged.** A record holding only the token is still accepted, as the vendor designed it. The generic data API still omits `identifier` from every `sys_verification` row. diff --git a/.changeset/25112-magic-link-ttl.md b/.changeset/25112-magic-link-ttl.md deleted file mode 100644 index bfb741cdd11..00000000000 --- a/.changeset/25112-magic-link-ttl.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): the Magic Link mail states the lifetime the link really has, five minutes - -Clause-②: no - -Metadata-protocol: none - -The `auth.magic_link` mail said "This link expires in 10 minutes", while the link it carried lived 300 seconds: `magicLink()` was mounted without `expiresIn`, so better-auth's own default applied, and the mail's `expiresInMinutes` was a separate hard-coded `10`. The mount now states `expiresIn: 300` explicitly, and the mail's `expiresInMinutes` is derived from that same value, so every locale of the mail now reads "5 minutes". The link's lifetime does not change. Nothing to do on upgrade; a customised `auth.magic_link` template that hard-codes its own number should read `expiresInMinutes` instead. diff --git a/.changeset/25118-dcr-infer-native.md b/.changeset/25118-dcr-infer-native.md deleted file mode 100644 index 4cb0c7f33e8..00000000000 --- a/.changeset/25118-dcr-infer-native.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): an MCP client that registers over OAuth dynamic client registration with a loopback or custom-scheme redirect, and no `application_type`, is registered as a native client instead of being refused - -Clause-②: no (a runtime security-boundary change the maintainer ruled; no published type, export or metadata key moves) -Metadata-protocol: none - -Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register at `POST /api/v1/auth/oauth2/register` with a loopback `http` redirect (`http://localhost:PORT/...`, `http://127.0.0.1:PORT/...`) or a custom-scheme redirect, and send no `application_type`. The OAuth provider used to fill the missing type with `web` and refuse both redirect forms (`invalid_redirect_uri: web clients require https redirect URIs on non-loopback hosts`), so these clients could not connect to the MCP endpoint over OAuth and API keys were the only working path. - -plugin-auth now passes `clientRegistrationDefaultApplicationType: 'infer'` to `@better-auth/oauth-provider`. A dynamic registration that omits `application_type` is classified `native` when any redirect URI uses a non-http(s) scheme or `http` on exactly `localhost`, `127.0.0.1` or `[::1]`, and every redirect URI is then validated by the native rule. Otherwise it stays `web`. What this changes and what it keeps: - -- Accepted as `native`: the body a local MCP client sends (a public client with a loopback or custom-scheme redirect). -- Unchanged: an `application_type` the client sends is never overridden; `https://example.com/cb` alone stays `web`; `http://example.com/cb` and `http://localhost.example.com/cb` stay refused; with dynamic client registration off, every registration is still refused. -- Also accepted as `native`: a confidential registration (`client_secret_basic`, or no `token_endpoint_auth_method`, which the provider fills as `client_secret_basic`) with a loopback or custom-scheme redirect and no `application_type`, which is issued a `client_secret`. The same body was already accepted with `application_type: 'native'` spelled out, so no new client shape becomes registrable. -- PKCE stays required at `/oauth2/authorize` for every one of these clients, and every token still needs the user's sign-in and consent. - -The Setup form's own OAuth application door (`/sys-oauth-application/register`) is not affected. diff --git a/.changeset/25119-cloud-connection-one-rule.md b/.changeset/25119-cloud-connection-one-rule.md deleted file mode 100644 index 41cd8c417f1..00000000000 --- a/.changeset/25119-cloud-connection-one-rule.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -fix(cloud-connection)!: one rule for the Cloud Connection capability — the page's record and its three connection-changing requests require `manage_platform_settings`, like its Setup entry; a server holding the environment service key also refuses `bind/start` and `bind/poll` - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (a signed-in user who holds Setup Access, `setup.access`, without Manage Platform Settings, `manage_platform_settings`, such as an organization administrator under the default permission sets, can no longer connect or disconnect the server, and is no longer served the connection's details), shipped as `patch`: the change is to which callers the routes serve, not to the metadata protocol. - -The Cloud Connection page's Setup entry sits in the Integrations group, which requires `manage_platform_settings`. The page's routes still took `setup.access` alone, so an organization administrator, whose default permission set carries Setup Access but not Manage Platform Settings, was not shown the entry but could open the page by its address and connect or disconnect the server. One rule now covers the entry, the page and the routes: - -- **Refused now:** `POST /api/v1/cloud-connection/bind/start`, `POST /api/v1/cloud-connection/bind/poll` and `POST /api/v1/cloud-connection/unbind` answer a signed-in user who does not hold `manage_platform_settings` with `403` and the standard `PERMISSION_DENIED` code, and the message names that capability. Holding `setup.access` is no longer enough. The refusal still comes before any call to the cloud and before the stored credential is touched. -- **Narrowed now:** `GET /api/v1/cloud-connection/status` serves `environmentId` and `connection` only to a user who holds `manage_platform_settings`. Every other caller, an organization administrator included, is served `bound`, `runtimeId` and `provider`, as an anonymous caller is. -- **Declared on the entry:** the Cloud Connection navigation entry now declares `requiredPermissions: ['manage_platform_settings']` itself, the capability its group already required, so the entry and the routes read one rule from this package. The page carries no permission key of its own (a page has none); what it shows is served by these routes. -- **Refused on a platform-managed server:** a server holding the environment service key (`controlPlaneApiKey`, or `OS_CLOUD_API_KEY`) already refused `unbind` with `409` and `RESOURCE_CONFLICT`. It now refuses `bind/start` and `bind/poll` the same way, after the sign-in and capability checks and before any call to the cloud or any write to the stored credential. A device connection completed there used to store a credential the service key always overrode, while presenting that key to the cloud, which could move the environment's cloud-side connection record. The message now says the connection cannot be connected or disconnected here, and that nothing was sent to the cloud and nothing was stored or cleared. -- **Unchanged:** a user who holds `manage_platform_settings`, which the default platform-administrator permission set carries, is served exactly as before on a server without the service key. Anonymous callers still get `401`, and a session the authentication policy is holding still gets its `403`. The device-code flow's steps, the status answer for a permitted caller, and the four other session routes (`install`, `installation`, `installed`, `org-packages`) are unchanged. - -**Who acts:** a deployment that relies on organization administrators, or another set holding only Setup Access, to connect its server grants `manage_platform_settings` to those users, through a permission set or a position, and they are served as before. diff --git a/.changeset/25120-mcp-package-objects.md b/.changeset/25120-mcp-package-objects.md deleted file mode 100644 index 97d00343ebf..00000000000 --- a/.changeset/25120-mcp-package-objects.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime)!: the `/mcp` bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`) - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (the data doors' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests a data door serves, not what a metadata author can write. - -The `/mcp` bridge answered `list_objects` and `describe_object` from the `metadata` service, and the API-exposure gate every runtime data door asks (`callData`) read the same service. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package or a plugin registers during the boot. So `list_objects` answered a short list as a complete `totalCount`, `describe_object` answered "not found" for every such object, and the gate read the missing copy as "no declaration" and served it. REST listed and refused the same objects. - -- **Before.** On that kernel, an object a package or a plugin registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way. -- **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does. -- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb on `/mcp`, and every well-formed request on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. On that door, as on REST, a body that is not a record is still answered `400 VALIDATION_FAILED` first. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing. -- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before. -- **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. An agent that read aggregates of a disabled package's objects through `aggregate_records` was relying on the same kind of defect: re-enable the package to restore access, as REST says. diff --git a/.changeset/25121-spec-openapi-viewer-prose.md b/.changeset/25121-spec-openapi-viewer-prose.md deleted file mode 100644 index 7f14b83bdfd..00000000000 --- a/.changeset/25121-spec-openapi-viewer-prose.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): `api.enableOpenApi` and the `api.documentation.enabled` refusal name only the OpenAPI document; the retired docs viewer page is no longer described - -Clause-②: no -Metadata-protocol: none - -The REST server no longer serves an HTML viewer page at `GET {basePath}/docs`; it serves the machine-readable OpenAPI document at `GET {basePath}/openapi.json`, gated by `api.enableOpenApi`. The spec text now says only that: - -- **`RestApiConfigSchema.enableOpenApi`.** The description read "Enable OpenAPI 3.1 spec & docs viewer endpoints", and the TSDoc named a Scalar-rendered viewer at `GET {basePath}/docs`. Both now name the one endpoint the flag mounts, `GET {basePath}/openapi.json`. -- **The `api.documentation.enabled` refusal.** Its prescription said `api.enableOpenApi: false` leaves "the `/openapi.json` document and its `/docs` viewer unmounted". It now says the switch leaves the `/openapi.json` document unmounted. The key is refused exactly as before, at the same path, with the same opening sentence; only the closing words changed. -- **The `rest-api-config-dead-keys-retired` upgrade entry** (shown by `os migrate meta` and the upgrade guide) drops the viewer from its replacement and acceptance text the same way. - -No schema accepts or refuses anything new, no default changes, and no export moves. diff --git a/.changeset/25123-scaffold-peer-ledger-retired.md b/.changeset/25123-scaffold-peer-ledger-retired.md deleted file mode 100644 index aa784cbc5ad..00000000000 --- a/.changeset/25123-scaffold-peer-ledger-retired.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch -'create-objectstack': patch ---- - -fix(cli): retire the scaffold's five stale `peerDependencyRules` entries on the better-auth 1.7.12 pin - -Clause-②: no -Metadata-protocol: none - -A project scaffolded by `objectstack init` or `npx create-objectstack` no longer carries a `peerDependencyRules.allowedVersions` block in its `pnpm-workspace.yaml`. All five entries suppressed peer mismatches that better-auth 1.7.12 no longer has: `better-auth` declares no `better-sqlite3` peer, and `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim` and `@better-auth/sso` each peer `@better-auth/utils` as `^0.4.2 || ^0.5.0`, which the resolved 0.5.0 satisfies. `allowedVersions` only silences a report, so a scaffold resolves the same lockfile as before and its first `pnpm install` stays free of unmet-peer warnings. Projects scaffolded earlier can keep or delete the block; either way nothing changes. diff --git a/.changeset/25128-tenant-column-never-null-comments.md b/.changeset/25128-tenant-column-never-null-comments.md deleted file mode 100644 index 0aad4fd92c9..00000000000 --- a/.changeset/25128-tenant-column-never-null-comments.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the `systemFields` docblock no longer says `organization_id` stays NULL on single-tenant stacks - -Clause-②: no (comment and docblock text only: no accept set, response, status, error, outcome or export moves) -Metadata-protocol: none - -The `organization_id` bullet of `ObjectSchema`'s `systemFields` docblock now says what the runtime does: org-scoping stamps the tenant column on insert (under `single` with the Default Organization), and it is never NULL (ADR-0131 D1). Only the column's index is still gated on multi-tenant mode. No schema, `.describe()` text or behavior changes. diff --git a/.changeset/25129-composite-title-stored-field.md b/.changeset/25129-composite-title-stored-field.md deleted file mode 100644 index 970680c6392..00000000000 --- a/.changeset/25129-composite-title-stored-field.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/skills': patch ---- - -docs(spec, skills): the `titleFormat` description and the objectstack-data skill no longer steer a composite record title that must be queried into a formula field - -Clause-②: no -Metadata-protocol: none - -The `titleFormat` description (and its generated copies on the object, metadata and migration reference pages) and the `objectstack-data` skill's `nameField` and `titleFormat` rows told an author to migrate a composite record title to a `formula` field with `returnType: 'text'` designated as `nameField`. A formula value is computed on read and stored nowhere, so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell against it. Both now say what the record-title lint and ADR-0079 D3 already say: a composite title that import, sort or search must match is a stored computed field, a non-`formula` field that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`; a text formula designated as `nameField` stays legal for a title shown for display only. - -The same skill's "Computed column" bullet (`rules/relationships.md`) said a stored field's `expression` is a client compute hint and the sent value is stored verbatim. The engine computes the expression from the row the write stores and stores the result, so a client-sent value never stands; the bullet now says so. - -No key, type, default or accepted value changes; only the text an author reads changes. diff --git a/.changeset/25133-refuse-orgless-production-boot.md b/.changeset/25133-refuse-orgless-production-boot.md deleted file mode 100644 index 1fddf894cec..00000000000 --- a/.changeset/25133-refuse-orgless-production-boot.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli)!: a production `os serve` that composes no organization object is refused whether or not `--server`, so `--no-server` no longer writes ownerless rows - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** boot-refusal narrowing (ADR-0131 D1, "NULL is not a state", and D9). It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. - -- FROM: a production `os serve --no-server` (no `--dev`; `os start` has no `--no-server` flag) booted the kernel when the stack's `auth` tier was on and no auth secret resolved, or when the stack mounted no auth at all (the `auth` tier off, `--preset minimal` included, with no `AuthPlugin` of its own), warning `⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`. Neither boot composes an organization object, so every row it wrote, its inline seed first, landed with no organization. Measured on an 18.0.0-born store: a deleted seed row was written back with `organization_id` NULL. On a fresh store the seed failed instead, every write refused by the NOT NULL tenant column. Both refusal texts named "run with --no-server" as a remedy. -- TO: both boots are refused whether or not `--server`, before any plugin starts. That includes a production `os serve --preset minimal`: the minimal preset composes no `auth` tier, so unless the stack mounts `AuthPlugin` itself it is refused, kernel-only or not (with `--server` it already was), so nothing is written and the database is never opened. That covers a `--no-server` migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`), which runs the same kernel bootstrap. Neither refusal text offers `--no-server` any more: each says a kernel-only boot does not avoid it. - -**Unchanged:** `os dev` and `--dev` (a development boot resolves its fallback secret, and a no-auth development boot is still refused only when it serves); an embedded kernel (`createStandaloneStack`, `@objectstack/objectql/core`), which never runs this command; and the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's), which keeps its warn-only path. `os start` always hands its `serve` child an auth secret, so it reaches the no-auth case only. - -**The remedy.** -- Refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes, e.g. `OS_AUTH_SECRET=$(openssl rand -hex 32)`). -- Refused because the stack mounts no auth: enable auth (add the `auth` tier, or mount `AuthPlugin` in `plugins`) and set `OS_AUTH_SECRET`. Declaring the stack's own objects `tenancy: { enabled: false }` does not lift this refusal: the platform's own system objects are tenant-scoped, so a production stack with no organization object would still write ownerless rows. -- A cloud migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`) must carry `OS_AUTH_SECRET` in production; without it the job is now refused, naming the variable. - -**Release-notes correction.** The 18.0 release page says a production `os serve` with no auth secret can "serve without the data API (`--no-server`)". That remedy is withdrawn: `--no-server` is refused the same way. The pending changeset `24583-7b3-organizationless-counter-retired` is corrected in this change to match. diff --git a/.changeset/25134-migrate-plan-nullable-tenant-columns.md b/.changeset/25134-migrate-plan-nullable-tenant-columns.md deleted file mode 100644 index a2e2a1e4404..00000000000 --- a/.changeset/25134-migrate-plan-nullable-tenant-columns.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -feat(cli): `os migrate plan` lists the tenant columns still nullable and names the voluntary organization-ownership ceremony; the ceremony reports a rotation shard already NOT NULL as such (#25134) - -Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves. - -Metadata-protocol: none - -A database the published 18.0.0 created is certified at birth with its tenant columns (`organization_id`) nullable, and `os migrate organization-ownership`, which tightens them to `NOT NULL`, is voluntary on it. Nothing told the operator that. - -- **`os migrate plan`: a new advisory section.** It lists every tenant column the database still holds nullable (table and column) and names `os migrate organization-ownership` as the remedy. Each column says whether the ceremony tightens it or leaves it, and why. The ceremony leaves a rotation shard created before shards were created `NOT NULL`, `sys_email_template` (its rows are mirrors the ceremony deletes, and the table retires), a table its inventory exempts, and every table on a database whose transport cannot rebuild a table (the Turso remote face). When the ceremony would tighten none of the listed columns, the section says so and does not offer it. A fresh install lists nothing. A column named `organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a tenant column and is not listed. -- **Advisory only.** It is not drift: it proposes no change, adds nothing to `total` or `changes`, and the exit code does not change. Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr, remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }` when the plan could not read it. -- **The ceremony's `not-null` step.** A rotation shard that the driver created `NOT NULL` used to be reported as `unsupported`, with a note saying each new shard is created nullable. That has not been true since the driver began creating shards `NOT NULL`. Such a shard is now `already-not-null` (counted in `alreadyNotNull`) and gets no note. A shard rotated in before then stays `unsupported`, and its note says it is dropped when its period leaves the base table's retention. - -**What to do.** Nothing is required: the server starts either way. To tighten the columns the plan lists, run `os migrate organization-ownership --out PLAN`, review it, then run `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`. diff --git a/.changeset/25138-mcp-stdio-package-objects.md b/.changeset/25138-mcp-stdio-package-objects.md deleted file mode 100644 index a96207a68b5..00000000000 --- a/.changeset/25138-mcp-stdio-package-objects.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`) - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (the stdio data verbs' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests the stdio transport serves, not what a metadata author can write. - -The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `list_objects` and `describe_object` from the `metadata` service, and gated its data verbs and its record resource on the same service's copy of the object. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_objects` answered `{ objects: [], totalCount: 0 }` for such a package, `describe_object` answered "not found" for each of its objects, and the gate read the missing copy as "no declaration" and served them. REST and the `/mcp` HTTP bridge listed and refused the same objects. - -- **Before.** On that kernel, an object a package registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record`, `aggregate_records` and the `objectstack://objects/NAME/records/ID` resource, and `create_record`, `update_record` and `delete_record` reached the store. Its `internal: true` fields were echoed in the `create_record` and `update_record` responses. -- **Now.** `list_objects` and `describe_object` answer from the listing `GET /api/v1/meta/object` serves. Every data verb and the record resource refuse such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` and the HTTP bridge do. Its `internal: true` fields are omitted from the write responses, and its nested-relation conditions are gated on their targets. -- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described; they are now omitted, as REST omits them. Every data verb and the record resource now refuse a disabled package's object `404 OBJECT_PACKAGE_DISABLED`, whatever the object declares: before, the stdio transport served it on every verb, because each verb asks the engine and never the protocol. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing. -- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before. A host with no `protocol` service, or one without the merged reads, keeps reading the `metadata` service. If the protocol's by-name read fails, the gate judges the `metadata` service's copy, as it did before. -- **What to do.** Nothing, if the declarations say what you mean. A stdio client that relied on reaching an object declared `apiEnabled: false` was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. A stdio client that read a disabled package's objects was relying on the same kind of defect: re-enable the package to restore access, as REST says. diff --git a/.changeset/25139-mcp-actions-merged-object-view.md b/.changeset/25139-mcp-actions-merged-object-view.md deleted file mode 100644 index 378d087509f..00000000000 --- a/.changeset/25139-mcp-actions-merged-object-view.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read an object's actions from the listing `list_objects` serves, so a package object's actions are listed and run on every kernel, and a disabled package's are omitted as its objects are - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled package's actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write. - -The HTTP MCP bridge (`POST /api/v1/mcp`) collected the actions declared on objects from the `metadata` service's `listObjects()`. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_actions` listed none of such a package's actions and `run_action` answered `Action 'NAME' not found on object 'OBJECT'`, while `list_objects` listed the object since #25120 and REST's `POST /api/v1/actions/OBJECT/NAME` ran the action. - -- **Before.** On that kernel, an action declared on an object a package registered during the boot (inside the object, or as a top-level action bound to it) was missing from `list_actions`; `run_action` answered `Action 'NAME' not found on object 'OBJECT'` (`Action 'NAME' not found` without `objectName`); and `resume_run` refused a run of such a flow action `403`, because no flow action it could see targets that flow. -- **Now.** The three tools collect object-embedded actions from the object definitions `list_objects` lists: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. Such an action is listed, resolved and dispatched behind the same gates as before (`ai.exposed`, `requiredPermissions`, the activation switch, the declared params, `ai.requiresConfirmation`), and runs as REST's `/actions` door runs it. -- **Also changed.** A disabled package's actions, where the `metadata` service still held a copy of its objects (every kernel without an `environmentId`, and a marketplace install on any kernel), were listed and run: a script action's body ran, a declarative update was refused at the record load. They are now omitted from `list_actions`, `run_action` answers `Action 'NAME' not found on object 'OBJECT'` for them, and `resume_run` refuses their runs `403`, as `describe_object` answers their objects "not found". When the protocol's merged listing throws, the three tools fail with that error, as `list_objects` does, instead of reading the `metadata` service's listing. -- **Unchanged.** Actions on objects that already had a copy are listed and run as before. Standalone `action` rows are still read from the `metadata` service (`loadMany('action')`), and an object-embedded declaration still wins over one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. REST's `/actions` door is untouched. -- **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose objects the rest of the platform no longer serves: re-enable the package to restore it. diff --git a/.changeset/25144-cbp-unservable-master-boot-refusal.md b/.changeset/25144-cbp-unservable-master-boot-refusal.md deleted file mode 100644 index e194c0e3d32..00000000000 --- a/.changeset/25144-cbp-unservable-master-boot-refusal.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql)!: a `controlled_by_parent` detail whose master the engine cannot compose a read over now refuses the boot, instead of booting and failing at read time - -Clause-②: yes (narrowing) - -Metadata-protocol: none - - - -**BREAKING** (a narrowing of what boots). A stack declaring a `controlled_by_parent` detail over either class of master below booted before this change. It is now refused at `kernel:bootstrapped`, before `kernel:listening` opens the socket. It ships as `patch` because no metadata protocol shape moves. - -ADR-0139 D5.2 and D5.3 record two classes of master the engine composes no `ReadableIds` leaf over, and refuses by name at read time: - -- **(a)** a master bound to another datasource than the detail. No driver evaluates a subquery across two datasources. -- **(b)** a master whose rows an object-scoped read middleware narrows (`registerMiddleware(fn, { object })`), for example `sys_attachment`, `sys_comment`, `sys_activity`, `sys_audit_log`, `sys_mail_message` or `sys_approval_request`. That WHERE is built at read time and is no part of the master's read scope, so a leaf over it would be wider than a read of the master. - -A `controlled_by_parent` detail derives its access from its one required master (ADR-0055 §1). A detail over such a master is a declaration the runtime cannot honour, so under ADR-0049 it is no longer admitted. `ObjectQLPlugin` refuses the boot with `INVALID_METADATA` / 422, naming each detail, its master field, the master and the class, and the remedies below. - -- The check is judged at `kernel:bootstrapped`, not at `kernel:ready` or `registerApp`. Several platform plugins (mailbox, audit, attachment storage) register their object-scoped read middleware from their own `kernel:ready` handler, which runs after the engine's, so only then are both facts final. -- It reuses the read path's own predicate (`hasObjectMiddleware` and the datasource binding resolution), so the boot rule and the read rule cannot drift. The read-time refusal is unchanged. -- New engine method: `ObjectQL#findUnservableControlledByParentMasters()` lists the offending details. - -No producer is known to be affected. HotCRM's six `controlled_by_parent` objects all sit on one datasource over masters with no object-scoped read middleware, and no in-repo declaration has either shape. - -### Remedy - -Pick one, for each detail the refusal names: - -- bind the detail and its master to one datasource (the same `datasource`, mapping rule or package default); -- or choose a master that no object-scoped read middleware narrows; -- or declare the detail with a sharing model other than `controlled_by_parent`, and grant its access directly. diff --git a/.changeset/25158-refuse-plugin-auth-load-failure.md b/.changeset/25158-refuse-plugin-auth-load-failure.md deleted file mode 100644 index 538b43f3b8a..00000000000 --- a/.changeset/25158-refuse-plugin-auth-load-failure.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli)!: a production `os serve` whose `@objectstack/plugin-auth` fails to load is refused, naming the load error, instead of booting with no organization object - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** boot-refusal narrowing (ADR-0131 D9), the third composition beside the two the previous change refused. It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. - -- FROM: a production `os serve` or `os start` (no `--dev`) whose stack enables the `auth` tier and resolves an auth secret, but whose `@objectstack/plugin-auth` fails to load — a missing install (silently) or a broken one (one `⚠ AuthPlugin failed to load: …` warning) — booted with no `AuthPlugin`. It composed no organization object, so every row the boot wrote landed with no organization: stored ownerless on an 18.0.0-born store (nullable columns, MongoDB), refused at NOT NULL on a fresh SQL table. -- TO: that boot is refused before any plugin starts, with or without `--server`, and the database is never opened. The refusal names the load error. The same holds when the AuthPlugin construction itself throws before it is mounted. - -**Unchanged:** `os dev` and `--dev` (a development boot keeps the warning and boots without auth); the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`), whose auth is each project's; a production boot with no auth secret or with no auth at all, which is still refused by the existing refusals; and an embedded kernel, which never runs this command. - -**The remedy.** Repair the install the error names: `@objectstack/plugin-auth` is a dependency of `@objectstack/cli`, so reinstall the deployment's dependencies (e.g. `npm ci` or `pnpm install --frozen-lockfile`). Running kernel-only (`--no-server`) does not avoid the refusal. diff --git a/.changeset/25159-api-docs-viewer-keys-retired.md b/.changeset/25159-api-docs-viewer-keys-retired.md deleted file mode 100644 index 54cb8ce3d51..00000000000 --- a/.changeset/25159-api-docs-viewer-keys-retired.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: the docs-viewer keys are retired — `ApiDocumentationConfig.ui` (with `ApiTestingUiConfigSchema` / `ApiTestingUiType`) and the REST API plugin config's `openApi.uiPath` / `openApi.uiFramework` are refused with a prescription naming the OpenAPI document (ADR-0049) - -Clause-②: no (narrowing) -Metadata-protocol: narrowed - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. - -Three keys declared an HTML API documentation viewer, and nothing ever read any of them: `ApiDocumentationConfig.ui` (a viewer type such as `swagger-ui`, `redoc` or `scalar`, served at `ui.path`, default `/api-docs`), and on the REST API plugin config `openApi.uiPath` (default `/api/docs`) with its viewer selector `openApi.uiFramework` (default `swagger-ui`). Since the docs viewer page was taken offline, the API origin serves no HTML viewer at all, only the machine-readable OpenAPI document, so none of the three could be honoured. They are retired under ADR-0049 enforce-or-remove, with no staged window. - -**What is refused:** each key is a `retiredKey()` tombstone. Authoring it is a `tsc` error at the site, and a parse error at the key's path whose message is the prescription, for every former value including the old defaults. The value schemas `ApiTestingUiConfigSchema` and `ApiTestingUiType`, with the `ApiTestingUiConfig`, `ApiTestingUiConfigParsed` and `ApiTestingUiType` types, are no longer exported: they had no consumer once `ui` was gone. - -**What stays accepted:** every other key of `ApiDocumentationConfigSchema`, `OpenApiGenerationConfigSchema` and `RestApiPluginConfigSchema`, byte-identically, defaults included. The mounted REST surface is unchanged: none of the three keys ever reached it. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `ui: { type: 'swagger-ui', … }` on an `ApiDocumentationConfig` | nothing: delete the key | -| `openApi: { uiPath: '/api/docs' }` on a REST API plugin config | nothing: delete the key | -| `openApi: { uiFramework: 'redoc' }` on a REST API plugin config | nothing: delete the key | -| `import { ApiTestingUiConfigSchema, ApiTestingUiType } from '@objectstack/spec/api'` | nothing: no replacement exists | - -The one-line fix: delete `ui`, `uiPath` and `uiFramework`, and point any viewer or client generator you run yourself at the OpenAPI document, `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both), mounted while `api.enableOpenApi` is on. - -## Who is affected, measured - -- **objectstack** at `b93c750c7`: zero readers and zero authorings outside `packages/spec`. The only identifier hits were a local `uiPath` variable for the unrelated `/ui/view` route in `packages/rest`; nothing in the tree parses the three schemas outside their own unit tests. A tree-scoped absence pin now holds that zero. -- **objectui** at the console pin `.objectui-sha` `56cf4d8e89`: zero hits for any of the keys or exported names, so the console build at the pin is unaffected. -- **Deployed configuration** was not measured. - -## The ADR-0087 disposition - -- `RETIRED_KEYS_BY_MAJOR[18]` gains `api/ApiDocumentationConfig:ui`, `api/OpenApiGenerationConfig:uiPath` and `api/OpenApiGenerationConfig:uiFramework`; `RETIRED_DEFS_BY_MAJOR[18]` gains `api/ApiTestingUiConfig` and `api/ApiTestingUiType`. -- The D3 semantic entry `api-docs-viewer-keys-retired` and its step-18 rationale fragment carry the prescription to `os migrate meta`, the upgrade guide and `spec-changes.json`. -- No D2 conversion: neither config is a stack collection member or a stored row, so no conversion seam would ever see one. `uiPath` and `uiFramework` carried Zod defaults, but nothing parses the REST API plugin config into a stored or built artifact, so no emitted default needs tolerating. - -The retirement kit: - -- the three tombstones and their retirement records (`packages/spec/src/api/documentation.zod.ts`, `packages/spec/src/api/plugin-rest-api.zod.ts`); the two value defs deleted; `REST_API_PLUGIN.md`'s example and feature list no longer name a viewer -- pin test `docs-viewer-keys-retirement.test.ts`: `code`, `path` and the prescription at each door (each schema and `RestApiPluginConfigSchema.openApi`), the `tsc` channel, the live controls, zero holders for the retired names, the ADR-0087 registration, and a tree-scoped absence walk -- generated artifacts follow the schema: `authorable-surface/`, `authorable-defaults/`, `json-schema.manifest/`, `api-surface/`, `export-origins/`, `declaration-map/`, the strictness-ledger counts and the reference pages for `api/documentation` and `api/plugin-rest-api` diff --git a/.changeset/25165-scaffold-vitest-advisory.md b/.changeset/25165-scaffold-vitest-advisory.md deleted file mode 100644 index 855d07f0d94..00000000000 --- a/.changeset/25165-scaffold-vitest-advisory.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch -'create-objectstack': patch ---- - -fix(cli,create-objectstack): new projects declare vitest `~4.1.12`, outside GHSA-qxg6-89mg-rqv8, and still install on npm 10 - -Clause-②: no -Metadata-protocol: none - -The vitest range every scaffold declares moves from `~4.0.0` to `~4.1.12`: the `create-objectstack` blank template, `os init -t plugin` and `os create plugin` (in `devDependencies`), and the npm `overrides` block that every standalone scaffold emits, `os init -t app` and `-t empty` included. `~4.0.0` resolved to vitest and `@vitest/mocker` 4.0.18, inside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12), which has no 4.0.x fix. A new project now resolves vitest and `@vitest/mocker` 4.1.12 or a later 4.1.x patch, and `npm audit` of a fresh scaffold lists no vitest or `@vitest/*` advisory. - -npm 10, the npm Node 22 bundles, still installs every scaffold. The `overrides.vitest` entry, which equals `devDependencies.vitest`, is what makes that hold: it settles vitest 4.1.12's contradictory optional peer (`@vitest/browser-webdriverio`), which npm 10 otherwise crashes on with `Cannot read properties of null (reading 'edgesOut')`. Measured on npm 10.9.4, 10.9.9 and 11.21.1. pnpm ignores npm's `overrides` field. - -**An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`** keeps its own `~4.0.0`. To move it, set `overrides.vitest` to `~4.1.12` in `package.json`, and `devDependencies.vitest` too where the project declares it, then run `npm install`. npm refuses an override that differs from the direct dependency (`EOVERRIDE`), so the two move together. diff --git a/.changeset/25168-external-binding-managed-datasource-refused.md b/.changeset/25168-external-binding-managed-datasource-refused.md deleted file mode 100644 index e9ef2f88bb8..00000000000 --- a/.changeset/25168-external-binding-managed-datasource-refused.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: an object's `external` binding on a managed datasource is refused at the parse and by `defineStack`, as ADR-0015 §4.2 declares - -Clause-②: no (narrowing) - -Metadata-protocol: narrowed - - - -**BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'`, and declares the rule "enforced at metadata-load time". No load door enforced it: `defineStack`, `os validate` and the object save door (`PUT /api/v1/meta/object/NAME`) all accepted an `external` binding on a managed datasource, the primary `default` included. The runtime then disagreed with itself about such an object: its schema sync treated it as federated and never created its table, while the boot validation skipped it and the write gate exempted it because its datasource is managed. - -- **FROM:** an object declaring `external` was accepted whatever its datasource: `datasource` omitted or `'default'`, or a datasource declared with `schemaMode: 'managed'` or with no `schemaMode`. -- **TO:** the rule is judged where each half is visible. - - An `external` binding whose object `datasource` is `'default'` (also the value when none is written) is refused by `ObjectSchema`'s parse at `external`: `ObjectSchema.create` throws at module load, `defineStack` and `os validate` refuse with `STACK_SCHEMA_INVALID` (422), and the object save door answers `422 INVALID_METADATA` and stores nothing. The platform's primary datasource is managed by construction. - - An `external` binding on a datasource the same stack declares with `schemaMode: 'managed'`, or with no `schemaMode`, is refused by `defineStack` (and so `os validate` and `os build`) with `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object. - - A package `defaultDatasource` or a `datasourceMapping` rule does not make an object federated: they route its queries, while the boot validation and the write gate read the object's own `datasource`. -- **Unchanged:** a binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted. A binding on a datasource the stack does not declare (registered by the host or another package, or stored at runtime) gets no verdict at these doors, because they cannot see its `schemaMode`. `defineStack(config, { strict: false })` skips the stack-level judgement by contract. A stored object row in the refused shape still registers, under a `[metadata_spec_invalid]` diagnostic, and its next save is refused; `os migrate meta --stored` lists each such row under `object-external-binding-managed-datasource-refused`. -- **The fix for an author:** set the object's `datasource` to the datasource that holds the remote table and declare that datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings, for example `datasource: 'warehouse'` beside `defineDatasource({ name: 'warehouse', schemaMode: 'external', external: { … } })`. An object whose rows ObjectStack should own drops `external` instead. diff --git a/.changeset/25174-rest-uuid-advisory.md b/.changeset/25174-rest-uuid-advisory.md deleted file mode 100644 index 466c5b07610..00000000000 --- a/.changeset/25174-rest-uuid-advisory.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch -'create-objectstack': patch ---- - -fix(cli,create-objectstack): new projects override `uuid` below 12.0.0 to `^11.1.1`, so `npm audit` lists no GHSA-w5hq-g745-h8pq entry - -Clause-②: no -Metadata-protocol: none - -`@objectstack/rest` depends on `exceljs ^4.4.0`, and exceljs 4.4.0, its newest release, depends on `uuid ^8.3.0`. GHSA-w5hq-g745-h8pq affects `uuid` below 11.1.1, so `npm audit` in a fresh npm-installed scaffold reported 9 moderate entries, all from that one advisory. The npm `overrides` block every standalone scaffold emits now carries `"uuid@<12.0.0": "^11.1.1"`: the `create-objectstack` blank template, `os init` (`-t app`, `-t plugin`, `-t empty`) and `os create plugin`. A new project resolves `uuid` 11.1.1 and `npm audit` lists no `uuid` entry, measured on npm 10.9.9 and 11.21.1. exceljs calls only `uuid`'s `v4()`, which `uuid` 11 keeps; exceljs's buffered and streaming writers, its loader and `@objectstack/rest`'s `parseXlsxToRows` were run on the installed tree. pnpm ignores npm's `overrides` field. - -The selector stops at `<12.0.0`, so a project can still add a newer `uuid` of its own: `npm install uuid@14` installs. A spec-less `npm install uuid` is refused with `EOVERRIDE` (npm reads it as `uuid@*`, which meets the selector); name the major instead. - -**An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`**, or any other npm project that installs `@objectstack/rest`, directly or through `@objectstack/runtime`, `@objectstack/cli`, `@objectstack/plugin-auth` or `@objectstack/verify`: add `"uuid@<12.0.0": "^11.1.1"` to the `overrides` block of its root `package.json` and run `npm install`. npm reads `overrides` only from the root manifest, so no `@objectstack/*` release can apply it for you. diff --git a/.changeset/25175-create-plugin-scaffold-test.md b/.changeset/25175-create-plugin-scaffold-test.md deleted file mode 100644 index be1aedd08fd..00000000000 --- a/.changeset/25175-create-plugin-scaffold-test.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os create plugin` ships a starter test, so a new project's `npm test` passes - -Clause-②: no -Metadata-protocol: none - -A project made by `os create plugin NAME` declared `"test": "vitest"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `src/index.test.ts`, which runs the checks the kernel runs on a plugin when it loads one: a `name`, an `init` function, and the protocol's `PluginSchema`. It boots no kernel; its header links the `@objectstack/verify` harness for tests that need one. The `test` script is now `vitest run`, one pass that exits. - -The starter test lives in `src/`, which `tsc` compiles, so the template also writes `tsconfig.build.json`: it extends `tsconfig.json` and excludes `src/**/*.test.ts`, and `build` and `dev` now run `tsc -p tsconfig.build.json`. `typecheck` still reads `tsconfig.json`, so a type error in a test still fails it, while `dist/` holds no compiled test and `npm test` after `npm run build` runs each test once. - -**An existing project made by an earlier `os create plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `src/index.test.ts` that a new `os create plugin NAME` writes. If you put it under `src/`, also copy `tsconfig.build.json` and point the `build` and `dev` scripts at it (`tsc -p tsconfig.build.json`), or `tsc` compiles the test into `dist/` and vitest runs it twice. diff --git a/.changeset/25175-plugin-scaffold-test.md b/.changeset/25175-plugin-scaffold-test.md deleted file mode 100644 index d24c1917547..00000000000 --- a/.changeset/25175-plugin-scaffold-test.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os init -t plugin` ships a starter test, so a new project's `npm test` passes - -Clause-②: no -Metadata-protocol: none - -A project made by `os init NAME -t plugin` declared `"test": "vitest run"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `package.test.ts` beside `objectstack.config.ts`. The test imports the package's stack, which runs `defineStack()` and its protocol check as the file loads, and asserts that the stack declares itself a `plugin` and contains every object `src/objects/index.ts` exports. A fresh project's `npm test` reports `Test Files 1 passed`. The test boots no engine. Its header links the `@objectstack/verify` harness for tests that need one. - -**An existing project made by an earlier `os init -t plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `package.test.ts` that a new `os init NAME -t plugin` writes. diff --git a/.changeset/25187-declared-rule-reader-contract.md b/.changeset/25187-declared-rule-reader-contract.md deleted file mode 100644 index 28e49336679..00000000000 --- a/.changeset/25187-declared-rule-reader-contract.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -feat(spec): `ISharingRuleService` declares the optional `declaredRuleNamed` reader that the catalog activation door uses for declared sharing rules - -Clause-②: yes (widening) -Metadata-protocol: none - -- **The contract member.** `ISharingRuleService` (`@objectstack/spec/contracts`) gains an optional `declaredRuleNamed?(name)` member. It returns `Promise<{ name: string; packageId?: string } | null>`: the rule the application declares under that name, or `null` when nothing servable is declared under it. The answer covers every organization, and it does not depend on whether the activation ledger has the rule switched off. `POST /api/v1/security/_activation/sharing_rule/:name` reads it to confirm the name it is asked to switch. Before this change the door read the method by duck type, and the contract did not mention it. -- **Optional, so no implementer breaks.** An implementation that serves no declared rules can leave it out. A caller probes for it and refuses fail-closed when it is absent. The activation door still answers `503 SERVICE_UNAVAILABLE` in that case, so the door's behaviour does not change. `@objectstack/plugin-sharing`'s `SharingRuleService` already implements it with a compatible signature. -- **The ledger's field description.** On `sys_metadata_activation`, the `metadata_type` description now lists every type the ledger holds: `flow`, `action`, `position`, `permission`, `sharing_rule` and `webhook`. It used to stop at `flow`, `permission`, …. diff --git a/.changeset/25189-email-template-hole-placement.md b/.changeset/25189-email-template-hole-placement.md deleted file mode 100644 index ccb13c6def4..00000000000 --- a/.changeset/25189-email-template-hole-placement.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/lint': patch -'@objectstack/formula': patch -'@objectstack/spec': patch ---- - -fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189) - -Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed) -Metadata-protocol: none - - - -**BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105). - -The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements. - -- **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`. -- **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well. -- **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200. -- **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added. -- **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged. -- **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape. -- **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's. -- **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes. -- **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value. diff --git a/.changeset/25190-retire-body-taking-render-exports.md b/.changeset/25190-retire-body-taking-render-exports.md deleted file mode 100644 index 2bbe9707b87..00000000000 --- a/.changeset/25190-retire-body-taking-render-exports.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-messaging': patch ---- - -fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment - -The hole-placement rule for notification templates is judged where a template is saved: the `sys_notification_template` save hooks. `renderNotification` took a template body inline and rendered it at once, with no save moment, so the rule could not see it. It leaves the published entry of `@objectstack/service-messaging`. Nothing in this repository imported it from that entry. The renderer stays inside the package: the email and sms channels still render every stored template through it, unchanged. - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. - -- FROM `renderNotification` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted. - -Unchanged: `interpolate` (the plain-text face, which the placement rule judges at no door), `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types; and how every stored template renders. diff --git a/.changeset/25194-delivery-title-names-recipient.md b/.changeset/25194-delivery-title-names-recipient.md deleted file mode 100644 index 5ab0f15b31e..00000000000 --- a/.changeset/25194-delivery-title-names-recipient.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/service-messaging": patch ---- - -fix(service-messaging): a notification delivery's title names the person it is for, not their raw user id (#25194) - -Clause-②: yes (widening) -Metadata-protocol: none - -The record title of a `sys_notification_delivery` row (Setup → Notification Deliveries) read `channel → recipient_id`, so a delivery to a user showed their raw user id. It now reads `channel → Person`: the user's name, or their email where the name is blank. A delivery to an address no user holds — an email kept as given, a phone number — is titled by that address, as before. - -The delivery object gains one optional field, `recipient_user_id` (Recipient User), a `sys_user` reference the title reads through. `emit()` sets it when the recipient is a user's id, from one `sys_user` read per emit, and leaves it empty for an address. Deleting the user empties it; the delivery stays, titled by the raw recipient. `recipient_id` keeps its type and meaning (the address: a user id, an email or a phone number), and is now labelled **Recipient**. - -Rows written before this change carry no `recipient_user_id` and keep their old title until the 90-day window ages them out. Nothing is migrated, and no delivery is routed by the new field. diff --git a/.changeset/25201-platform-object-tenancy-ledger-retired.md b/.changeset/25201-platform-object-tenancy-ledger-retired.md deleted file mode 100644 index cb3fc98e78b..00000000000 --- a/.changeset/25201-platform-object-tenancy-ledger-retired.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/objectql': patch -'@objectstack/driver-sql': patch ---- - -refactor(objectql)!: the per-table platform-object tenancy ledger is retired, and the tenant-audit mute is decided by the object's schema (ADR-0131 D13) - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** for code that imports the ledger from `@objectstack/objectql`. It ships as `patch` because the metadata protocol is unchanged. - -- **`@objectstack/objectql`: the ledger and its exports are gone.** No write ever depended on them: since ADR-0131 C8 stage 8b, every object with a tenant column takes the system-write rule's derived organization or is refused, whatever its name. The ledger's last reader was the engine's tenant-audit mute, which is now read off the schema. -- **The tenant-audit mute (`bypassTenantAudit` on an elevated write) is decided by the object's schema alone.** An elevated write is muted only when the object has no tenant column (`tenancy.enabled: false`, `systemFields.tenant: false`, or no tenant field) or is federated. A platform-namespace object that has a tenant column is now audited like any other. On a walled posture (`isolated` / `group`), an elevated write on such an object that reaches the SQL driver with no `tenantId` therefore prints the `[tenant-audit]` warning, once per object and operation. Nothing a write touches changes: the mute only decides whether that line is printed. -- **`@objectstack/driver-sql`: the `[tenant-audit]` warning names both readings.** It no longer tells every unscoped write to "pass tenantId". A write that belongs to one organization should run in that organization's context. A write that crosses organizations on purpose, such as a platform admin the organization wall exempts or a system job over every organization, needs no fix, and the caller declares it with `bypassTenantAudit: true`. The line still starts `[tenant-audit] on tenant-scoped object ""`, and its metadata is unchanged. - -**Migration** for code that imported the ledger: - -- FROM `PLATFORM_OBJECT_TENANCY` TO the object's schema: an object is in tenant scope when `resolveTenantFieldName(schema)` (still exported) answers non-null. `scripts/platform-object-tenancy-census.json` in the repository lists, per platform object, the schema declaration that puts it outside. -- FROM `classifyPlatformObjectTenancy(name)` TO `resolveTenantFieldName(schema) !== null` on the registered schema. -- FROM `isPlatformObjectOutOfTenantAuditScope(name)` TO `resolveTenantFieldName(schema) === null`. -- FROM `tenantScopedPlatformObjects()` TO the census's `reach: "in"` rows. -- FROM `PlatformObjectTenancy` TO delete it: no classification exists any more. -- FROM `PlatformObjectTenancyEntry` TO delete it: no classification exists any more. diff --git a/.changeset/25202-settings-no-organization-less-rung.md b/.changeset/25202-settings-no-organization-less-rung.md deleted file mode 100644 index 90b04258633..00000000000 --- a/.changeset/25202-settings-no-organization-less-rung.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -fix(service-settings)!: a tenant- or user-scope setting stored with no organization no longer answers every organization's read, and a caller with no organization reads and writes as the one organization the deployment derives - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** read narrowing (ADR-0131 D1 "nothing reads a NULL tenant value as an owner", and D8 "no layer has a NULL arm"). It changes which stored row a settings read takes, not what a metadata author can write, so it ships as `patch` with this banner. - -- FROM: a `tenant`- or `user`-scope `sys_setting` row written with no organization was every organization's fallback. A caller that named an organization read its own row, or failing that the organization-less one. A caller that named none read only organization-less rows behind a wall (`group` / `isolated`). Under `single` it read every row, an organization's first. Under `single` such a caller also wrote with no organization, so its write's identity probe missed the row the engine stamped with the Default Organization. The second save of a key then inserted a second row beside the first. On a SQL store the identity index refuses that insert and the save fails. On a store with no such index the save was lost: every reader kept answering the first value. -- TO: a caller reads its organization's rows and no others. For a caller that names no organization, the deployment names one (ADR-0131 D9, the predicate the engine applies to a system write): - - Under `single` with one organization, the caller reads and writes as that organization. This covers the process-wide readers: plugin-auth's email brand name and auth locale, and the anonymous `runtime/config` branding read. - - Behind a wall, or under `single` with no organization or several, the caller has no tenant or user rung. Its read answers from the global rung or the default, and its tenant-scope write is refused (`SETTINGS_VALIDATION`, `constraint: { scope: 'tenant' }`). - - A composition that registers no organization object (a lean embedding, kept by ruling) still stores and reads its rows with no organization. - -**Who is affected.** Only a store holding a tenant- or user-scope `sys_setting` row with a NULL `organization_id`. A fresh install holds none, nor does an 18.0.0-born store written through `os serve`: the measured 18.0.0 fixture holds two `sys_setting` rows, both owned. A v17 store holds none after `os migrate organization-ownership` under `single`, which gives such rows to the Default Organization. Under a walled posture the ceremony leaves such a row NULL and reports it, and from this release that row stops answering any organization. - -**The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each value again from inside the organization it belongs to (Setup → the settings page, or `PUT /api/settings/:namespace` with that organization active). A value meant for every organization belongs on a key declared at scope `global`. diff --git a/.changeset/25203-adr-0131-small-carriers.md b/.changeset/25203-adr-0131-small-carriers.md deleted file mode 100644 index 8eb2e085d2c..00000000000 --- a/.changeset/25203-adr-0131-small-carriers.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch -'@objectstack/plugin-security': patch -'@objectstack/cli': patch -'@objectstack/spec': patch ---- - -fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected - -Clause-②: no -Metadata-protocol: none - -- **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. -- **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. -- **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". -- **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. diff --git a/.changeset/25204-claim-orphan-org-rows-retired.md b/.changeset/25204-claim-orphan-org-rows-retired.md deleted file mode 100644 index 8be9e0996db..00000000000 --- a/.changeset/25204-claim-orphan-org-rows-retired.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/organizations': patch ---- - -fix(organizations)!: the first organization no longer adopts organization-less rows — `claimOrphanOrgRows` is retired - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** for a walled store (`group` or `isolated`) that holds application rows with no `organization_id` when its first organization is created, and for code that imports `claimOrphanOrgRows`. It ships as `patch`: no metadata an author writes changes. - -- **The first organization adopts nothing.** When the per-org seed replay landed no row and the new organization was the store's first, `OrganizationsPlugin` used to write that organization's id onto every organization-less row of every application object (`claimed N orphan seed row(s) for first organization …`). Being created first names no owner, so this was an owner by guess, which ADR-0131 D9 and D10 forbid. Under a wall, the only posture that mounts this plugin, the organization-ownership ceremony reports such a row, leaves it NULL and holds it against certification; the claim then gave the same row to whichever organization existed. Those rows now stay NULL. -- **A first organization with no seed replay logs that it starts empty**, the line every later organization already logs. The organization count the claim needed is no longer read. -- **Unchanged:** the per-org seed replay, the organization stamp on insert, the Default Organization bootstrap, and `claimOrgSeedOwnership`, which hands an organization's seeded rows to its owner. - -**What an operator does.** On a store this release creates or certifies there is no such row, so nothing. On a walled store an earlier release created that holds organization-less application rows, run `os migrate organization-ownership`: it attributes every row an anchor names, and lists each row it cannot attribute with its remedy instead of guessing an owner. - -The retired export: - -- FROM `claimOrphanOrgRows` TO no replacement; run `os migrate organization-ownership` for organization-less rows. diff --git a/.changeset/25206-walled-self-registration-grant-sole-membership.md b/.changeset/25206-walled-self-registration-grant-sole-membership.md deleted file mode 100644 index 61867e8d5ef..00000000000 --- a/.changeset/25206-walled-self-registration-grant-sole-membership.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -feat(cli): the ownership ceremony gives a walled organization-less self-registration grant its user's sole membership's organization - -Clause-②: no -Metadata-protocol: none - -Before the self-registration grant waited for the registrant's first membership, a sign-up under `isolated` or `group` wrote the grant (`sys_user_permission_set`) with no organization. `os migrate organization-ownership` reported every such row as unattributable, so each one held certification until the operator assigned its organization by hand. - -Under a wall the ceremony now derives the owner when the user's memberships (`sys_member`) all name one organization. That is where the runtime settles the grant today. The plan counts these rows under `sole-membership:user_id->sys_member`, and `--apply` writes them. - -A grant stays reported, never guessed and never deleted, in three cases: - -- its user is a member of several organizations, because which membership came first is not recorded; -- its user is a member of none; -- its user already holds the same permission set in that organization, because the grant's unique index would refuse the write. - -Each reported row's reason names its case. The remedy is unchanged: assign the organization, or delete the row by hand, then re-run `--apply`. Under `single` nothing changes, and the Default Organization takes every organization-less grant. diff --git a/.changeset/25208-default-organization-one-definition.md b/.changeset/25208-default-organization-one-definition.md deleted file mode 100644 index ddbc9f38f06..00000000000 --- a/.changeset/25208-default-organization-one-definition.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/plugin-auth': patch -'@objectstack/plugin-security': patch -'@objectstack/cli': patch ---- - -fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core - -Clause-②: yes (narrowing) -Metadata-protocol: none - - - -**BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. - -- **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. - - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. - - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. -- **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. - -**What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. diff --git a/.changeset/25211-external-binding-save-doors.md b/.changeset/25211-external-binding-save-doors.md deleted file mode 100644 index 0bfa4f785a9..00000000000 --- a/.changeset/25211-external-binding-save-doors.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: the metadata save doors and `composeStacks` refuse an object's `external` binding on a named managed datasource, from either side of the pair - -Clause-②: yes (narrowing) - -Metadata-protocol: narrowed - - - -**BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes. - -ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'` (an absent `schemaMode` is `'managed'` by the spec default). #25168 enforced the halves a parse and a single stack can see. A binding on a datasource stored or registered at runtime, or declared by another input of a composition, still got no verdict: the save door stored an `external` object on a managed datasource, stored a datasource re-saved as managed under objects bound to it with `external`, and `composeStacks` accepted the pair split across two inputs. The runtime then disagrees with itself about the object: its schema sync skips the table as federated while the boot validation and the write gate judge it as managed. - -- **FROM:** `PUT /api/v1/meta/object/NAME` stored an object with `external` and a `datasource` naming a stored or registered datasource declared `schemaMode: 'managed'` or with no `schemaMode`, in draft and publish mode and under `force`. `PUT /api/v1/meta/datasource/NAME` stored a datasource as managed (or with no `schemaMode`) while stored or registered objects bound it with `external`. `composeStacks` accepted an `external` object of one input bound to a managed datasource another input declares. -- **TO:** - - The object save door refuses the object with `422 INVALID_METADATA`, the finding at `external`, nothing stored. The datasource it judges against is the one the door's own by-name read serves: the environment's stored row, else the definition the code layer registers. - - The datasource save door refuses the datasource with `422 INVALID_METADATA`, one finding per bound object at `schemaMode`, the stored row unchanged. The objects it judges are the environment's active object rows and the registered objects no stored row overlays. An object's draft binds nothing until it is published. - - Both are judged in draft and publish mode, and again when a draft is published (`publishMetaItem` and each item of a package publish), against the other half as it stands then. `force` does not skip them. - - `composeStacks` refuses the cross-input pair with `STACK_CROSS_REFERENCE_INVALID` (422), in either input order and under every `manifest` strategy, `preserve` included. An input built with `strict: false` that holds the pair itself is refused here too, as with the artifact pass's other rules. - - Every finding is the spec predicate's, so it reads as the parse's and `defineStack`'s do: "Object 'OBJECT' declares `external` on datasource 'NAME', whose `schemaMode` is 'managed' …". -- **Unchanged:** the rule itself. A binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted on every door. A datasource no layer holds gets no verdict: the absence of a definition is no evidence about its `schemaMode`. The datasource-admin service's update (`PATCH /api/v1/datasources/NAME`) does not pass through the metadata save door and is not judged by this change. Rows already stored in the refused shape still load as before; a save of either side that leaves the pair inconsistent is refused, so the next object save waits until the datasource is external or the object drops `external`. -- **The fix for an author:** keep the pair consistent, in this order. To federate, save the datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings first, then bind objects to it. To make a datasource managed, remove `external` from the objects that bind it first, then save the datasource. - -This narrows the **Unchanged** bullet of the #25168 changeset, which said a binding on a datasource "registered by the host or another package, or stored at runtime" gets no verdict at the load doors: the metadata save doors now judge it, and `composeStacks` judges one another input declares. - -`@objectstack/spec/data`: exports `externalBindingDatasourceRefusal` and its result type `ExternalBindingDatasourceRefusal`, the one predicate behind the rule, so a runtime door resolves the datasource it can see and asks it rather than copying the judgement. Its behaviour is unchanged. diff --git a/.changeset/25229-page-window-refused.md b/.changeset/25229-page-window-refused.md deleted file mode 100644 index 9c29ce0cee4..00000000000 --- a/.changeset/25229-page-window-refused.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol)!: the Data API refuses a malformed or negative page window with a 400 naming the parameter as written, instead of a 500 or a silently ignored window - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** accept-set narrowing on the list doors (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`, and the runtime dispatcher's data list). It changes what a Data API caller can send, not what a metadata author can write, so it ships as `patch` with this banner. - -- **Before.** The list-query normalizer coerced the window with a bare `Number(...)`. A negative offset (`?$skip=-1`, `{"offset": -5}`) reached the SQL driver, which threw, and the caller got `500 INTERNAL_ERROR`. A limit that was not a number or was negative (`?$top=abc`, `?$top=-1`, `{"limit": -1}`), and an offset that was not a number (`?$skip=abc`), were dropped, so the caller got every row under a `200`. A blank `?$top=` became `0` and answered an empty page. A fractional window (`{"offset": 1.5}`, `{"limit": 1.5}`) was rounded by the driver. -- **Now.** `limit` / `offset` and their spellings `$top`, `top`, `$skip`, `skip` must be non-negative integers. Anything else is refused before any read with `400 INVALID_REQUEST`, and the message names the parameter the caller wrote: `Query parameter '$skip' must be a non-negative integer; got "-1".` A blank value is refused too (`?$top=`, `?$skip=`), not read as absent: an absent window means every row, so reading a blank one that way would be the silent widening this closes. -- **Unchanged.** A well-formed window pages exactly as before (`?$top=5&$skip=2`, `{"limit": 5, "offset": 2}`), and `0` stays valid on both (`$top=0` asks for the total alone). A non-number in a `POST` body (`{"limit": "ten"}`) is still the body schema's `400 VALIDATION_FAILED`. - -**The remedy.** Send the window as a whole count of zero or more, or leave the parameter out. diff --git a/.changeset/25230-engine-declares-wall-exemption.md b/.changeset/25230-engine-declares-wall-exemption.md deleted file mode 100644 index 931f4dcbdef..00000000000 --- a/.changeset/25230-engine-declares-wall-exemption.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the engine declares the organization wall's exemption to the driver, so an exempt platform admin's predicate write is not told to declare an option it cannot set - -Clause-②: no -Metadata-protocol: none - -Under a walled posture an exempt platform admin's predicate update or delete reaches the driver with no `tenantId`, because the wall lifted the scope on purpose (ADR-0131 D8). The SQL driver's `[tenant-audit]` line then told the caller to declare `bypassTenantAudit: true`, an option no REST caller can set. - -The engine now fills in `bypassTenantAudit: true` on the driver options when the Layer 0 verdict the security layer recorded for the operation is `none` and the same decision without the exemption would have walled it. It does not fill it in when no verdict was recorded (a by-id write, a system context, no security layer), under the `single` posture, for an insert, for an object outside the tenant machinery, or when the caller supplied its own value. - -Diagnostics only: the driver's scope was already the recorded verdict's, and the option only silences the warning. diff --git a/.changeset/25230-tenant-audit-insert-carries-organization.md b/.changeset/25230-tenant-audit-insert-carries-organization.md deleted file mode 100644 index c8d5fbbbf63..00000000000 --- a/.changeset/25230-tenant-audit-insert-carries-organization.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -fix(driver-sql): the `[tenant-audit]` warning no longer fires for an insert whose rows carry their organization - -Clause-②: no -Metadata-protocol: none - -A `create` or `bulkCreate` that reaches the SQL driver with no `options.tenantId` used to print `[tenant-audit] … without options.tenantId` even when every row already named its organization in its tenant column (`organization_id`). Such an insert is confined to the organizations its rows name, so the line had nothing to report. A walled boot (`OS_TENANCY_POSTURE=isolated`) printed one for every elevated seed insert: 32 of its 55 lines. - -The line is now skipped when every inserted row carries a non-empty tenant column. It still fires for an insert where any row lacks the column, and for every `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` and `upsert` with no `tenantId`, whatever the payload says: there the column is a value being written, not the statement's scope. The line's text and prefix are unchanged. - -Diagnostics only: no write changes. Turso's local face and the SQLite WASM driver inherit the change; the in-memory and MongoDB drivers have no such warning. diff --git a/.changeset/25232-artifact-package-source.md b/.changeset/25232-artifact-package-source.md deleted file mode 100644 index 2798af09b47..00000000000 --- a/.changeset/25232-artifact-package-source.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/objectql': minor ---- - -feat(spec,core,objectql): one package entry of a release artifact may state its own `source`, and the registry records it - -Clause-②: yes -Metadata-protocol: added - -`ArtifactPackageSchema` (one `packages[]` entry of an assembled release artifact) gains an optional `source: 'code' | 'marketplace'` beside `manifest`. The values are the new `ArtifactPackageSourceSchema`, the subset of `InstalledPackageSourceSchema` a load path can register; `authored` and any other value are refused at the entry gate as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). - -The `manifest` service registers each package with the `source` its entry states, so a merged artifact that carries marketplace installs beside the deployment's own code lists them as `marketplace` on `GET /api/v1/packages`. An entry that states nothing takes the registering door's statement as before (`register(artifact, { source })`, `code` when absent), so existing artifacts register unchanged. An entry's own statement wins over the door's. - -`@objectstack/core` adds `resolveArtifactPackageEntries(artifact)`, which returns each package to register as `{ manifest, source? }` in dependency order. `resolveArtifactPackageOrder` is unchanged and still returns the bodies alone. diff --git a/.changeset/25245-mcp-stdio-object-resources-merged.md b/.changeset/25245-mcp-stdio-object-resources-merged.md deleted file mode 100644 index 241ef166aea..00000000000 --- a/.changeset/25245-mcp-stdio-object-resources-merged.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -fix(mcp)!: the `objectstack://objects` resources read the stdio transport's merged object view, so they list and describe every object `list_objects` and `describe_object` answer, and omit a disabled package's objects as those tools do - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** (the object resources stop serving a disabled package's objects), shipped as `patch`: it changes which reads the stdio transport answers, not what a metadata author can write. - -The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `resources/read` of `objectstack://objects` and `objectstack://objects/NAME` from the `metadata` service. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `objectstack://objects` answered `{ objects: [], totalCount: 0 }` for such a package and `objectstack://objects/NAME` answered `404 RESOURCE_NOT_FOUND` for each of its objects, while `list_objects` and `describe_object` listed and described them. - -- **Before.** On that kernel, an object a package registered during the boot was missing from `objectstack://objects`, and `objectstack://objects/NAME` answered `{ error: 'Object "NAME" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }` for it. -- **Now.** Both resources read the view `list_objects` and `describe_object` answer from: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. `objectstack://objects/NAME` answers the same body `describe_object` answers for that name. -- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed by `objectstack://objects` and described by `objectstack://objects/NAME`. They are now omitted, and `objectstack://objects/NAME` answers `404 RESOURCE_NOT_FOUND` for them, as `describe_object` answers "not found". When the protocol's merged listing throws, both resource reads fail with that error, as `list_objects` does, instead of answering the `metadata` service's listing. -- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and described as before. The completeness verdict is still the `metadata` service's: a loader outage still withholds `totalCount` and answers `partial: true`, and a miss during an outage still answers `503 SERVICE_UNAVAILABLE`. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. A host that calls `MCPServerRuntime.bridgeResources` itself reads the `metadata` service as before. -- **What to do.** Nothing. A stdio client that read a disabled package's object through these resources was reading a stale copy the rest of the platform no longer serves: re-enable the package to restore it, as REST says. diff --git a/.changeset/25248-localization-fallback-organization-scope.md b/.changeset/25248-localization-fallback-organization-scope.md deleted file mode 100644 index b540bad41b0..00000000000 --- a/.changeset/25248-localization-fallback-organization-scope.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core)!: `resolveLocalizationContext`'s direct `sys_setting` fallback reads the caller's organization's rows only, never another organization's row or a row with no organization outside a lean embedding - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** read narrowing (ADR-0131 D8 "no layer has a NULL arm"). It changes which stored row a localization read takes when the settings service cannot answer, not what a metadata author can write, so it ships as `patch` with this banner. - -- FROM: when the settings service was absent or threw (a namespace not yet bound, a failed organization or posture read), `resolveLocalizationContext` read `sys_setting` tenant rows for `timezone` / `locale` / `currency` with no organization filter and took the first row per key. A signed-in user of one organization could be answered with another organization's timezone, locale or currency, or with a row written with no organization, and the success cache could hold that answer. -- TO: the fallback reads with the reach the settings service reads with: - - A caller that names an organization reads that organization's rows, under every posture. - - A caller that names none reads the one organization ADR-0131 D9 derives under `single`. Behind a wall (`group` / `isolated`, the requested posture), at zero or several organizations, or when the organization read fails, it reads nothing and the built-ins `UTC` / `en-US` answer. - - A composition that registers no `sys_organization` (a lean embedding) reads the rows written with no organization, the only rows it can hold. - -**Who is affected.** Only a deployment whose settings service fails a localization read, and only for an organization with no tenant localization row of its own, or a store holding organization-less tenant localization rows. A healthy settings service never reaches this read. - -**The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each localization value again from inside the organization it belongs to (Setup → Localization, or `PUT /api/settings/localization` with that organization active). diff --git a/.changeset/25249-settings-self-bound-organization.md b/.changeset/25249-settings-self-bound-organization.md deleted file mode 100644 index 0ce237212b6..00000000000 --- a/.changeset/25249-settings-self-bound-organization.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -fix(service-settings)!: a host that binds its own engine and states no organization source reads and writes as the organization ADR-0131 D9 derives through that engine, not as no organization - -Clause-②: no (narrowing) -Metadata-protocol: none - - - -**BREAKING** read narrowing (ADR-0131 D8/D9, follow-up to the organization-less rung fix). It changes which organization a host's settings reads take, not what a metadata author can write, so it ships as `patch` with this banner. - -- FROM: a host that bound its own engine (`new SettingsService({ engine })` or `bindEngine(engine)`) and stated no `organization` source read and wrote, for a caller that names no organization, as a composition with no organization object: its rows with no organization answered, and its writes stored none. -- TO: such a host's service asks D9 itself, through the engine it holds (`resolveGuestOrganization`, the call the plugin's source makes). Under `single` with one organization it reads and writes as that organization; at none or several it has no tenant or user rung. An engine that exposes no `registry` cannot say the organization object is absent, so D9 asks `sys_organization`. Over a store with no such object, a tenant-rung read or write by a caller naming none is then refused as organization-less (reads answer from the global rung or the default, tenant writes are refused with `SETTINGS_VALIDATION`), or raises `AuthzStoreUnavailableError` naming `sys_organization` where the engine does not know that object. No engine at all (the in-memory store), a stated source, an engine whose `registry.getObject` answers, and `SettingsServicePlugin` (`os serve`, `os dev`) are unchanged. - -**Who is affected.** Only a host that binds its own engine and states no source: a control-plane mock or an embedded runtime. On a composition with an organization object it now reads and writes the derived organization's rows, and its rows with no organization stop answering. On one without, it meets the refusal above. A deployment booted through `SettingsServicePlugin` is not affected. - -**The fix.** State the composition at the bind site: `service.bindEngine(engine, audit, { organization: async () => ({ kind: 'no-organization-object' }) })` (a host that hands its engine to the constructor binds it this way instead), or hand over an engine whose `registry.getObject` can answer. diff --git a/.changeset/25261-jit-idp-locale-claim.md b/.changeset/25261-jit-idp-locale-claim.md deleted file mode 100644 index 984cf70df39..00000000000 --- a/.changeset/25261-jit-idp-locale-claim.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -feat(plugin-auth): a JIT sign-in reads the IdP `locale` claim into the user's language preference, so a hosted environment follows the cloud account language - -Clause-②: no -Metadata-protocol: none - -When an external IdP sign-in creates or refreshes a user just-in-time (the cloud as IdP on a hosted environment through `oidcProviders`, any generic-OAuth or social provider, or a `@better-auth/sso` OIDC provider), the standard OIDC `locale` claim now becomes `sys_user.locale`, the column the console reads as the signed-in user's language and messaging reads per recipient. - -- **First creation:** the claim sets the preference. -- **A later sign-in:** the claim updates it only while the user has not picked a language in the environment itself. A local choice wins and is never overwritten; clearing it hands the column back to the IdP. -- **An unknown or unsupported claim:** nothing is stored (an IdP-written value is cleared), so the deployment default applies. The claim is closed against the locales the i18n service reports (`i18n.supportedLocales` when the app declares them), matched exact, then case-insensitive, then by base language (`en-US` → `en`, `ja` → `ja-JP`), and the supported spelling is stored. The underscore spelling (`zh_CN`) reads as the tag it stands for. -- **No claim:** nothing changes. - -What the IdP last wrote is recorded per user in `sys_verification` (identifier `idp-locale:` plus the user id), so an IdP-written value can be told apart from a local choice; no schema changes. Applying the claim never fails a sign-in: a store that refuses the write is reported at `warn` and the user keeps the language they had. diff --git a/.changeset/25264-console-branded-head.md b/.changeset/25264-console-branded-head.md deleted file mode 100644 index ff1859b1e55..00000000000 --- a/.changeset/25264-console-branded-head.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -feat(cli): the console document carries the runtime branding in its ``, description and Open Graph tags, written on the server, so a link preview shows the product's name - -Clause-②: no -Metadata-protocol: none - -`createConsoleStaticPlugin` (what `os serve` mounts at `/_console/`) used to serve the console shell with the empty `<title>` it ships, and no description or `og:*` tags. Only the shell's inline script set the title, in the browser, from `GET /api/v1/runtime/config`. A link-preview crawler or `curl` runs no script, so it saw a blank title. - -Every console HTML answer (the shell and the SPA fallback) now reads the branding from that same door, in-process and for the same request, and writes: - -- `<title>` and `og:title` from `branding.productName`, the name the script sets as the tab title; -- `<meta name="description">` and `og:description` from `branding.pwaDescription`; -- `og:image` from `branding.logoUrl`, resolved against the request origin; -- `og:url`, the request origin (its scheme from `x-forwarded-proto` when a proxy states `http` or `https`). - -Every value is HTML-escaped, and a missing value writes no tag. A composition that mounts no `runtime/config` door gets the document exactly as before. A branding read that fails serves the document as before and logs one `warn`. - -Nothing to change: `OS_PRODUCT_NAME`, `OS_PWA_DESCRIPTION`, `OS_LOGO_URL`, the `RuntimeConfigPlugin` options and, under `single`, a saved Setup → Branding name and logo reach the tags the way they reach the console. diff --git a/.changeset/25267-cel-has-indexed-receiver.md b/.changeset/25267-cel-has-indexed-receiver.md deleted file mode 100644 index 726aa5fa32c..00000000000 --- a/.changeset/25267-cel-has-indexed-receiver.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/formula': minor ---- - -fix(formula): `has()` over a selection whose receiver is an index or a call result evaluates as a presence test, as the CEL spec defines it - -Clause-②: yes (widening) -Metadata-protocol: added - -The CEL spec defines `has(e.f)` for any expression `e`. The engine used to refuse every such argument whose selection chain was not rooted in a plain name — `has(data.environments[0].storage.usedMb)`, `has(record.items[0].name)`, `has(dyn(x).y)` — with `has() invalid argument`, at evaluation and at `compile` / `validateExpression` alike. A page or view `visibleWhen`, whose publish gate checks syntax but not types, published such a predicate and then failed open in the console. - -- **What now works.** The call is a presence test on the last field: `true` when the receiver has the key, `false` when it does not, and a fault (`No such key`) when a step before the last one is absent, exactly like `has(record.a.b)`. `evaluate`, `compile`, `validateExpression`, `firstUndeclaredReference` and `inferCelType` all give that one answer. -- **What widens.** Object, flow, action, sharing-rule and hook predicates that `validateExpression` used to refuse for this argument now pass. They evaluate as written. -- **What is still refused.** An argument the spec itself refuses: a bare name (`has(status)`) or an index as the last step (`has(record.items[0])`). Test a key with `'key' in m`, or a list position with `size(list) > n`. -- **Nothing to migrate.** A predicate already written in the `'key' in e` form keeps working and answers the same. diff --git a/.changeset/25269-page-window-declared.md b/.changeset/25269-page-window-declared.md deleted file mode 100644 index c0a98304bea..00000000000 --- a/.changeset/25269-page-window-declared.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/metadata-protocol': patch ---- - -fix(spec)!: the published list-query schemas declare the page window the Data API enforces — a non-negative integer — instead of any number - -Clause-②: no (narrowing) -Metadata-protocol: none - -<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no metadata type, authorable metadata key, object definition or stored shape is added, removed, renamed or re-shaped. What narrows is two request schemas a Data API caller is validated against (`FindDataRequestSchema`, through `QueryWithTransportSchema` and `QueryTransportParamsSchema`, and `HttpFindQueryParamsSchema`), and every value they now refuse was already refused by the list doors since the page-window normalizer landed. There is nothing for `objectstack migrate meta` to rewrite; the remedy is in the request (send a whole count, or leave the parameter out). --> - -**BREAKING** accept-set narrowing on two published request schemas. It changes what a schema-validated Data API request may carry, not what a metadata author can write, so it ships as `patch` with this banner. - -- **Before.** `FindDataRequestSchema` (the `POST /api/v1/data/:object/query` body) declared `limit` / `offset` / `top` as any number and `$top` / `$skip` / `skip` as any number or string, and `HttpFindQueryParamsSchema` (the `GET /api/v1/data/:object` querystring) declared `top` / `skip` as `z.coerce.number()`. A client generated from them, or a request validated against them, was told `-1`, `1.5` or a blank `?top=` was valid, and the door then refused it with a `400`. -- **Now.** Each of those parameters declares the page window the door enforces: a non-negative integer, or on the string-carrying spellings the digits a querystring carries it as (`'50'`). `-1`, `1.5`, `NaN`, `Infinity`, a blank or whitespace-only string, and a string that is not a count fail the parse. The bound is exported once, as `QueryPageWindowSchema` / `QueryPageWindowParamSchema` (`@objectstack/spec/data`), and the list-query normalizer in `@objectstack/metadata-protocol` now judges every window by that same schema, so the two accept sets are one declaration. -- **One answer moves, and only its code.** On `POST /api/v1/data/:object/query` (and the runtime dispatcher's `POST` twin) a malformed window is now refused by the body schema, like every other malformed body: `400 VALIDATION_FAILED` with `fields[0].field` naming `query.limit` / `query.offset`, where it was `400 INVALID_REQUEST` from the normalizer. Still a `400`, still before any read. `GET /api/v1/data/:object` has no body schema and answers `400 INVALID_REQUEST` naming the parameter as before. -- **Unchanged.** A well-formed window parses and pages exactly as before, `0` included (`$top=0` asks for the total alone). Windows inside `expand` and the engine and driver contracts (`QuerySchema`, `EngineQueryOptionsSchema`) are not narrowed: no door refuses those. - -**The remedy.** Send the window as a whole count of zero or more, or leave the parameter out. diff --git a/.changeset/25271-single-reslug-default-anchor.md b/.changeset/25271-single-reslug-default-anchor.md deleted file mode 100644 index 5507a909ba3..00000000000 --- a/.changeset/25271-single-reslug-default-anchor.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth)!: under `single`, no slug change makes another organization the Default Organization — re-slugging to `default` captured the platform-admin anchor - -Clause-②: no (narrowing) -Metadata-protocol: none - -<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is a runtime write: a `sys_organization` slug changed to `default` under `single`. --> - -**BREAKING** for a `single` deployment that holds several organizations, none slugged `default`, and re-slugs one of them to `default`. Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). A re-slug to `default` therefore made that organization the Default Organization, and every holder of a grant it owned a platform administrator: an organization owner could do it through better-auth's `POST /auth/organization/update`, and a seed or a plugin through an engine update. - -- **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED` by the last-administrator guard's `sys_organization` `beforeUpdate` hook, for every engine write, `isSystem` included, so better-auth's `organization/update` answers it too. A read the guard cannot complete refuses as well. -- **Removed, with no in-place replacement: moving the Default Organization.** FROM: with several organizations, an operator could move `default` from one to another in two writes (the current one re-slugged away, then another re-slugged to `default`). TO: the second write is refused, because it is exactly the capture above, whoever makes it. An atomic swap was never possible (the slug is unique). -- **Stranding.** Re-slugging the Default Organization AWAY while other organizations exist is still admitted when an administrator stays standing (the last-administrator guard's update half, unchanged). After it, no organization names the Default Organization, so no organization can take `default`, the previous holder included, until the store holds one organization again or declares a wall. Do not re-slug a Default Organization away on a `single` deployment that holds several organizations. -- **Unchanged:** a slug changed to any other value; every slug change under a walled posture (`isolated`, `group`), where no grant anchors and a slug is just a slug; the data API, whose user-context PATCH and update-mode import were already refused a `slug` by the ADR-0092 identity write guard. - -**What an operator does.** Keep another slug. To move the anchor, move the grant rather than the slug: an `admin_full_access` grant re-owned by the Default Organization anchors, and a grant owned elsewhere does not. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes. diff --git a/.changeset/25278-restore-doors-save-refusal-family.md b/.changeset/25278-restore-doors-save-refusal-family.md deleted file mode 100644 index 2a9c174b6da..00000000000 --- a/.changeset/25278-restore-doors-save-refusal-family.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: `rollbackMetaItem` and `revertCommit` refuse a stored version the save door refuses, instead of writing it back live - -Clause-②: no (narrowing) - -Metadata-protocol: narrowed - -<!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused, page-default-record-page-one-per-object-refused, api-endpoint-anonymous-elevated-flow-refused) no rule is added or changed: the restore doors now ask the save door's existing refusals, and the ledger entries of the three that carry one already describe the refused shape and its fix --> - -**BREAKING** (what a metadata restore writes narrows), shipped as `minor` under the 18.x convention for breaking changes. - -The two restore doors write a stored history body back as the active row: `POST /api/v1/meta/TYPE/NAME/rollback` (`rollbackMetaItem`) and the restore limb of a commit revert (`revertCommit`, which `rollbackToPackageCommit` also uses). They asked only the save door's `name` and permission-set owner judges, and none of the refusals `saveMetaItem` runs after its schema gate. So a version saved before a refusal existed, or one half of a pair whose other half moved after the version was saved, came back live. - -- **FROM:** a rollback or a commit revert restored a version that the save door would refuse today: - - a hook with no `body`; - - a record page marked `isDefault` for an object whose default another page now holds; - - a datasource restored to `schemaMode: 'managed'` (or no `schemaMode`) while objects bind it `external`; - - an object restored with `external` on a datasource that is managed now; - - a `runAs: 'system'` flow that an anonymous (`authRequired: false`) endpoint names; - - a body the shared runtime authoring rules refuse; - - a body a domain plugin's authoring gate refuses (plugin-security's object posture gate, for example). -- **TO:** each restore door asks those refusals through the save door's own functions, with the save door's code and status, and stores nothing on a refusal. The active row, the history and the registry stay as they were. - - `rollbackMetaItem` answers the refusal: `400 VALIDATION_ERROR` for the hook, `422 DEFAULT_RECORD_PAGE_CONFLICT` for the page, `422 INVALID_METADATA` for the datasource pair, the flow and the authoring rules, and the plugin gate's own code. - - `revertCommit` reports the refused item in `failed[]` with that code and reverts the commit's other items, as it does for its other per-item refusals. -- **Unchanged:** a version the save door accepts restores as before. The restore does not run the schema parse on the history body, does not apply the read-only-base create refusal (that refusal judges a package a caller names for a new item; a restore names none and writes into the active row's own package binding), and keeps the write-only credential channel's current credential. The runtime authoring rules' advisories are not added to the restore response. -- **The fix for an author:** a refused restore names the same problem the save door names. Restore a different version, or first change the other half of the pair (for example, remove `external` from the bound objects before restoring the datasource as managed), then restore again. diff --git a/.changeset/25279-stored-external-binding-known-datasources.md b/.changeset/25279-stored-external-binding-known-datasources.md deleted file mode 100644 index b744d67acb4..00000000000 --- a/.changeset/25279-stored-external-binding-known-datasources.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/metadata-protocol': patch ---- - -fix(spec): `os migrate meta --stored` lists a stored object bound `external` to a stored or registered managed datasource - -Clause-②: yes (widening) - -Metadata-protocol: none - -The stored-shape listing for `object-external-binding-managed-datasource-refused` resolved an object's named datasource only from the stack being converted. A stored object row reaches the conversion chain alone, so the listing judged only a binding on the primary datasource (`'default'`), while the object save door also refuses one on a datasource stored or registered beside the object. The stored-metadata pass therefore did not list a stored object row bound `external` to a managed datasource, the row whose next save is refused with `422 INVALID_METADATA`. - -- **Now:** for each stored `object` row carrying `external` and naming a datasource, the pass reads that datasource the way the object save door does (the environment's stored row, else the definition the code layer registers; both go through one read) and hands it to the chain. The row is listed under `rows[].todos` with this entry's id exactly when that door refuses its next save. A datasource declared `schemaMode: 'external'` or `'validate-only'`, and a datasource no layer holds, give no listing, as at the door. Nothing is rewritten. -- **`@objectstack/spec`:** `ApplyConversionsOptions` (and so `StoredConversionOptions` for `applyConversionsToStoredItem`) gains an optional `knownDatasources`, a map of datasource definitions by name that the caller's door can see beyond the stack. Only the refused-shape listing reads it, so only when an `onTodo` sink is passed. A datasource the stack declares is read from the stack first. Without it, behaviour is unchanged. diff --git a/.changeset/25281-visibility-has-argument-refused.md b/.changeset/25281-visibility-has-argument-refused.md deleted file mode 100644 index 73e41a07473..00000000000 --- a/.changeset/25281-visibility-has-argument-refused.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/spec': minor ---- - -fix(lint)!: a page or view `visibleWhen` that hands `has()` an argument other than a field selection is refused at publish - -Clause-②: no (narrowing) - -Metadata-protocol: narrowed - -<!-- adr-0087: registered visibility-predicate-has-argument-refused --> - -**BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes. - -CEL defines `has()` in one form, `has(e.f)`, with `f` a field name after a dot. The engine refuses any other argument as `has() invalid argument` on every evaluation, whatever the record holds. Every other predicate surface already refused it through `validateExpression`. The page and view `visibleWhen` door did not, so `has(status)` and `has(record.items[0])` published clean, and the console, which renders a faulting predicate as visible, drew the guarded element for every user. - -- **FROM:** a `visibleWhen` on a page component, a form view section or a section field could hand `has()` a name alone (`has(status)`), an index as the last step (`has(record.items[0])`), or any other argument that is not a field selection (`has(record.?a)`, `has(upper(record.x))`, a literal, an operator), and `os validate`, `os build`, `os lint` and the page and view save doors accepted it. -- **TO:** each is refused as `visibility-predicate-has-argument`, an error, at those doors (the save doors answer `422 INVALID_METADATA`). The finding quotes the argument and the engine's wording, and its hint names the spelling to write. `os explain visibility-predicate-has-argument` says why. -- **Unchanged:** `has(e.f)` for any `e`, including a selection over an indexed or called receiver (`has(record.items[0].name)`); every other predicate, and nothing about the types that flow through one is judged. A `has(status) && status == 'x'` keeps its `visibility-bare-identifier` finding on the unguarded `status`, beside the new one. A stored page or view row in the refused shape still loads and is served; its next save is refused, and `os migrate meta --stored` lists each such predicate under `visibility-predicate-has-argument-refused`. -- **The fix for an author:** write `has(record.status)` or `'status' in record` for a field (`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether a list has a first element (`'k' in e` for a bracketed string key). diff --git a/.changeset/25287-hide-2fa-display-when-off.md b/.changeset/25287-hide-2fa-display-when-off.md deleted file mode 100644 index 764e8074387..00000000000 --- a/.changeset/25287-hide-2fa-display-when-off.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix: the `sys_user` page and list views show no 2FA state on a deployment where two-factor authentication is off - -Clause-②: no -Metadata-protocol: none - -`plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it on, and `/api/v1/auth/config` then serves `features.twoFactor: false`. The platform's own `sys_user` declarations showed a 2FA state anyway, with no way to act on it: a "Two-Factor Enabled: No" highlights chip, a 2FA column in the `all_users` and `me` list views, a "2FA Enabled" view tab that could only ever be empty, and a Security-tab "Two-Factor Authentication" heading above an empty action bar. - -- **Record page, Security tab:** the 2FA heading, its caption, the 2FA actions and the divider that closes the block carry one component `visibleWhen`, `has(features.twoFactor) && features.twoFactor == true`. With 2FA off, or before the auth config has loaded, the block is not drawn. With 2FA on, the block also shows the user's 2FA state (`two_factor_enabled`) beside the enable, disable and backup-code actions. -- **Record page, highlights:** `two_factor_enabled` is no longer a highlight chip. A highlight field has no per-field gate, so the state moved into the gated Security block. The details grid still hides the field. -- **List views:** `all_users` and `me` drop the `two_factor_enabled` column, and the `two_factor` ("2FA Enabled") list view is retired. A list column or view has no feature gate, and adding one would be a new authorable key. - -The `two_factor_enabled` field itself is unchanged. A deployment with 2FA on that wants the column back can add it to its own list view. diff --git a/.changeset/25292-cloud-managed-hidden-doors.md b/.changeset/25292-cloud-managed-hidden-doors.md deleted file mode 100644 index 8abe37c628b..00000000000 --- a/.changeset/25292-cloud-managed-hidden-doors.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -feat(platform-objects): the console hides the six doors that add people while `features.cloudManaged` is on - -Clause-②: no -Metadata-protocol: none - -A cloud-managed environment's people arrive from the organisation's identity provider, so the console no longer offers the ways to add them by hand there. While `GET /api/v1/auth/config` serves `features.cloudManaged: true`, these actions are hidden: - -- `invite_user` on `sys_user`, `sys_member`, `sys_invitation` and `sys_organization`; -- `sys_user.create_user`; -- `sys_user.set_user_password`. This one is hidden in the console only: the server keeps answering it for a local (`env_native`) account, so the break-glass admin stays editable. - -Each action keeps the gate it already had (`organization` or `admin`), and the new gate is added beside it, so nothing changes where the flag is off. diff --git a/.changeset/25292-features-cloud-managed.md b/.changeset/25292-features-cloud-managed.md deleted file mode 100644 index 5647d26f532..00000000000 --- a/.changeset/25292-features-cloud-managed.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): declare the console flag `features.cloudManaged` — a `PUBLIC_AUTH_FEATURES` entry with a new hide-when-on gate semantics, and `AuthFeaturesConfigSchema` declares it - -Clause-②: yes (widening) -Metadata-protocol: added - -The managed (cloud-hosted, SSO-only) mode gets its console flag. People in a managed environment arrive through the organisation's identity provider, so the console hides the doors that would add them any other way. - -- **`PublicAuthFeatureSemantics` gains `'hide-when-on'`.** The two existing semantics gate an input on a capability being present (`opt-in` lowers to `features.X == true`, `default-on` to `features.X != false`). The new one gates an input on a mode being absent: `featureGatePredicate` lowers it to `features.X != true`, so the input is hidden when the flag is on and visible when it is off or not served at all. -- **`PUBLIC_AUTH_FEATURES.cloudManaged`** (`surface: 'crud'`, `semantics: 'hide-when-on'`) books six gated inputs: the four `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`, `sys_organization`), `sys_user.actions.create_user` and `sys_user.actions.set_user_password`. `PUBLIC_AUTH_FEATURE_NAMES` lists it, so `requiresFeature: 'cloudManaged'` is now accepted on an action or param and lowers to `visible: features.cloudManaged != true`. -- **`AuthFeaturesConfigSchema.cloudManaged`** is an optional boolean on the `GET /api/v1/auth/config` response's `features`. Absent means not managed. - -What to do: nothing. Every key and value accepted before is accepted unchanged, and every existing flag lowers to the same predicate as before. diff --git a/.changeset/25292-plugin-auth-serves-cloud-managed.md b/.changeset/25292-plugin-auth-serves-cloud-managed.md deleted file mode 100644 index baa9f3f0a37..00000000000 --- a/.changeset/25292-plugin-auth-serves-cloud-managed.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -feat(plugin-auth): `GET /api/v1/auth/config` serves `features.cloudManaged` - -Clause-②: yes (widening) -Metadata-protocol: none - -The public auth config's `features` gains `cloudManaged`, a boolean read from `isCloudManaged()` in `@objectstack/core` (true when the process runs with `OS_CLOUD_MANAGED=1`). It is `false` on every other deployment. The console reads it to hide the doors that add people by hand (invite, create user, set password) in a cloud-managed environment, whose people arrive from the organisation's identity provider. - -What to do: nothing. The payload only gains a key. diff --git a/.changeset/25294-is-cloud-managed.md b/.changeset/25294-is-cloud-managed.md deleted file mode 100644 index 19a275ce580..00000000000 --- a/.changeset/25294-is-cloud-managed.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/cloud-connection': patch ---- - -feat(core): `isCloudManaged()` is the one reader of `OS_CLOUD_MANAGED`, and a production boot that sets it without a bound control plane refuses to start - -Clause-②: yes (widening) -Metadata-protocol: none - -- **New exports of `@objectstack/core`:** `isCloudManaged()`, `resolveControlPlaneApiKey(configured?)` and `resetCloudManagedForTesting()`. `isCloudManaged()` is synchronous and answers `true` when `OS_CLOUD_MANAGED` is exactly `1`, `false` when it is unset or empty; any other value is refused with an error that names it. The answer is memoized for the process; tests that set the variable call `resetCloudManagedForTesting()` before and after. -- **The fuse.** On a production boot (not `--dev`, which sets `NODE_ENV=development` only when `NODE_ENV` is unset, and not `NODE_ENV=test`; so `NODE_ENV=production os serve --dev` counts as a production boot and is refused the same way), `OS_CLOUD_MANAGED=1` with `OS_CLOUD_API_KEY` unset or empty refuses: `ObjectKernel.bootstrap()` and `LiteKernel.bootstrap()` reject before any plugin initialises, and `isCloudManaged()` throws the same error. The cloud sets both variables on the runtimes it hosts. A self-hosted deployment that has `OS_CLOUD_MANAGED=1` in a copied env file now fails to boot; remove the variable, which a self-hosted deployment never needs. -- **One caveat:** the fuse reads the environment only. A runtime that binds its control plane solely through `CloudConnectionPlugin`'s `controlPlaneApiKey` option, and also sets `OS_CLOUD_MANAGED=1` in production, is refused. Set `OS_CLOUD_API_KEY` instead. -- **`@objectstack/cloud-connection`:** the plugin resolves its control-plane credential through `resolveControlPlaneApiKey(controlPlaneApiKey)`, so the plugin's "platform-managed" answer and the fuse share one derivation. Its behaviour is unchanged: the option wins, then `OS_CLOUD_API_KEY`, trimmed. diff --git a/.changeset/25296-rest-actions-disabled-package.md b/.changeset/25296-rest-actions-disabled-package.md deleted file mode 100644 index 8db46b7a57a..00000000000 --- a/.changeset/25296-rest-actions-disabled-package.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime)!: REST's `POST /api/v1/actions/OBJECT/NAME` refuses an action on an object whose owning package is disabled `404 OBJECT_PACKAGE_DISABLED`, the data plane's answer for that object, before any handler runs - -Clause-②: no (narrowing) -Metadata-protocol: none - -<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is one answer of the `/actions` door: an invocation on an object whose owning package is disabled is refused with the protocol's own `404 OBJECT_PACKAGE_DISABLED`, read through the by-name object read the data verbs' exposure gate already makes. The one function added (`refuseDisabledPackageObject` in `action-execution.ts`) is not published by `index.ts`. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> - -**BREAKING** (the `/actions` door stops running a disabled package's object actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. - -Disabling a package takes its objects off every door: `/data` refuses them `404 OBJECT_PACKAGE_DISABLED`, `GET /api/v1/meta/object` omits them, and the MCP `run_action` tool answers "not found" for their actions. REST's `/actions` door did not ask. It resolved the action's declaration from the engine's schema registry, where a disabled package's objects stay registered, and dispatched. - -- **Before.** On every kernel shape, `POST /api/v1/actions/OBJECT/NAME` on an object of a disabled package ran a script action's handler and answered `200` with its result; a declarative update was refused only at the record load; an action gated by `requiredPermissions` answered a caller without them `403`; and a name nothing declares answered the "no declaration" `404`. -- **Now.** Each of those is refused `404 OBJECT_PACKAGE_DISABLED`, with the error the data plane raises for the object, before any gate reads the action's declaration and before any record load or dispatch. The anonymous baseline still answers first. -- **Unchanged.** Actions on objects of an enabled package run as before, as does every gate after this one. Re-enabling the package restores the actions at once: nothing records the refusal. An object-less action (`POST /api/v1/actions/global/NAME`) names no object and is not affected. The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) is untouched. -- **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it. diff --git a/.changeset/25297-mcp-standalone-actions-merged-listing.md b/.changeset/25297-mcp-standalone-actions-merged-listing.md deleted file mode 100644 index 50de164aec7..00000000000 --- a/.changeset/25297-mcp-standalone-actions-merged-listing.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read the actions no object embeds from the merged `action` listing, so a package's object-less action and an environment-authored one are listed and run, and a disabled package's is omitted - -Clause-②: no (narrowing) -Metadata-protocol: none - -<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where the HTTP MCP bridge reads the standalone `action` items from: the protocol's merged listing, the one `GET /api/v1/meta/action` already serves, instead of the metadata service's `loadMany('action')`. The functions that change (`collectActionDeclarations`, `resolveActionByName`, `invokeBusinessAction`, `resumeActionRun`) are not published by `index.ts`, and each gains only an optional trailing input. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> - -**BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled compiled-artifact package's object-less actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write. - -The HTTP MCP bridge (`POST /api/v1/mcp`) collected the `action` items that no object embeds, the object-less (`global`) actions among them, from the `metadata` service's `loadMany('action')`. That call reads the service's loaders alone. They hold the compiled artifact's actions, but not an action a package registers through the `manifest` service (a capability plugin during the boot, or a marketplace install) and not an environment-authored action saved through `PUT /api/v1/meta/action/NAME`. Those live in the registry and in `sys_metadata`, which `GET /api/v1/meta/action` reads, and REST's `POST /api/v1/actions/global/NAME` ran them. - -- **Before.** On every kernel, such an action was missing from `list_actions`, `run_action` answered `Action 'NAME' not found` (`Action 'NAME' not found on object 'global'` when scoped), and `resume_run` refused a run of such a flow action `403`. -- **Now.** The three tools collect those items from the protocol's merged `action` listing, the one `GET /api/v1/meta/action` serves, through the same read the object listing uses. Such an action is listed, resolved and dispatched behind the same gates as before (`ai.exposed`, `requiredPermissions`, the activation switch, the declared params, `ai.requiresConfirmation`, a registered handler for a script action), and runs as REST's `/actions` door runs it. -- **Also changed.** A compiled-artifact package's object-less action stayed in the loader after the package was disabled, so it was still listed and run. It is now omitted from `list_actions`, and `run_action` answers `Action 'NAME' not found` for it, as the package's objects are omitted since #25120. When the merged `action` listing throws, the three tools fail with that error instead of reading the `metadata` service. -- **Unchanged.** The compiled artifact's actions are listed and run as before. An object-embedded declaration still wins over a standalone one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading `loadMany('action')`. REST's `/actions` door is untouched. -- **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose package the rest of the platform no longer serves: re-enable the package to restore it. diff --git a/.changeset/25302-get-record-limit-positive-integer.md b/.changeset/25302-get-record-limit-positive-integer.md deleted file mode 100644 index ba6a6be191d..00000000000 --- a/.changeset/25302-get-record-limit-positive-integer.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: a flow `get_record` node's `limit` is declared a positive integer, so a fraction, `0` or a negative is refused at every door that parses a flow - -Clause-②: no (narrowing) -Metadata-protocol: narrowed - -<!-- adr-0087: not-required (already-registered flow-builtin-node-config-values-refused) registered before this change, amended in this diff: the entry's surface, replacement and reason now name a get_record limit that is not a positive integer, and the stored-row listing reports such a node under that id --> - -**BREAKING** (a value the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage. - -The `get_record` executor reads around the Data API's page-window normalizer: with `limit` above 1 it calls `data.find` with that LIMIT as written, and otherwise it calls `findOne`. The spec declared `limit` as any number, so a flow could declare a window the runtime did not honour as written. - -- **FROM:** `GetRecordConfigSchema.limit` was `z.number()`. `limit: 2.5` parsed and reached the driver as `LIMIT 2.5`; `limit: 0`, `limit: -1` and `limit: 0.5` parsed and silently read one record through `findOne`. -- **TO:** `limit` is `z.number().int().positive()`. Every door that parses a flow refuses any other number at `nodes.N.config.limit` with the code `node-config-refused-by-contract`: `FlowSchema.parse`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `objectstack validate` and `objectstack compile`, the metadata save door, `AutomationEngine.registerFlow`, and an artifact's parse. The generated JSON Schema declares `type: integer` with an exclusive minimum of 0. -- **Fix:** write the count as a whole number of at least 1. `limit: 1` (or no `limit`) reads one record as `record`; a larger whole number reads a `records` list of at most that many. -- **Unchanged:** every positive whole `limit`, and a node with no `limit`. A string `limit` (`'10'`, `'{page.size}'`) was already refused as the wrong type. -- **Not the Data API's page window.** `QueryPageWindowSchema` admits `0`, which asks a list door for the total alone. A `get_record` node always reads at least one row, so its bound is its own. - -Data at rest: a flow already stored with such a `limit` is refused whole when it registers. At boot it is skipped with a `warn` naming it and its trigger is not armed, while the flows beside it register. `objectstack migrate meta` reports it under `flow-builtin-node-config-values-refused`. No conversion rewrites it, because only the author knows the count they meant. - -No producer writes such a value: every `get_record` `limit` in this repository's examples and tests, and in HotCRM, is a positive whole number. diff --git a/.changeset/25308-rest-global-actions-disabled-package.md b/.changeset/25308-rest-global-actions-disabled-package.md deleted file mode 100644 index 9fe6c489005..00000000000 --- a/.changeset/25308-rest-global-actions-disabled-package.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime)!: REST's `POST /api/v1/actions/global/NAME` answers an object-less action of a disabled package the "no declaration" `404` a name nothing declares gets, before any handler runs - -Clause-②: no (narrowing) -Metadata-protocol: none - -<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where the `/actions` door reads a standalone action's declaration: through the protocol's by-name `action` read, which already withholds a disabled package's item, so such an action resolves to no declaration. No function is added or published; `resolveRouteActionDeclaration` keeps its signature. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> - -**BREAKING** (the `/actions` door stops running a disabled package's object-less actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. - -Disabling a package withholds its items: `GET /api/v1/meta/action/NAME` answers `404`, and the MCP `run_action` tool answers "not found". The `/actions` door refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, but an object-less action names no object to ask about. It resolved a standalone declaration from the engine registry or the metadata service, where a disabled package's items stay, and dispatched. - -- **Before.** On every kernel shape, `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) on a disabled package's top-level action with no `objectName` ran its handler and answered `200` with its result. So did an enabled object's route that resolves the same object-less action, and the package's top-level action bound to another, enabled package's object. -- **Now.** The door reads a standalone action through the protocol's by-name `action` read (`GET /api/v1/meta/action/NAME`'s read, on its execution face), which withholds a disabled package's item. Each request above answers the "no declaration" `404`, the same status, code and message, but for the name, that a name nothing declares gets, before any gate, record load or handler. A by-name read that fails is the metadata plane failing to answer: the door's existing `503`, never a fall-back to a copy. An action on an object of a disabled package still answers `404 OBJECT_PACKAGE_DISABLED` first. -- **Also.** The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) resolves through the same function, so a disabled package's standalone action is answered there by that door's own "no declaration" `404` too, where a flip wrote a ledger row before. Re-enable the package to switch it. -- **Unchanged.** An enabled package's object-less actions, and an environment-authored one (no owning package), run as before, through a disable of any package. Re-enabling the package restores its actions at once: nothing records the answer. A host whose protocol has no by-name read resolves from the registry and the metadata service, as before. -- **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it. diff --git a/.changeset/25315-core-security-single-definitions.md b/.changeset/25315-core-security-single-definitions.md deleted file mode 100644 index bc2a3e989f7..00000000000 --- a/.changeset/25315-core-security-single-definitions.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/plugin-auth': patch -'@objectstack/plugin-security': patch -'@objectstack/service-settings': patch ---- - -refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security - -Clause-②: yes (widening) -Metadata-protocol: none - -Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before. - -- **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing: - - behind a wall, no grant does; - - under `single`, the grant owned by the Default Organization does; - - an organization-less grant never does, and neither does any grant when no Default Organization can be named. - - Three readers call it: - - the authorization resolver passes the memoised Default Organization; - - the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it; - - the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion. - - Each reader keeps its own normalizer for a grant row's organization. -- **The organization-reach rule (ADR-0131 D8/D9).** New exports: - - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`; - - `organizationReachAdmits(reach, rowOrganizationId)`; - - `organizationReachRowOwner(reach)`; - - `organizationIdOf(value)`; - - `UNOWNED_ORGANIZATION_REACH`; - - the types `OrganizationReach` and `OrganizationReachFacts`. - - The rule decides whose `sys_setting` rows a caller may draw on: - - the caller's own organization; - - for a caller naming none where no wall stands, the organization D9 derives; - - nothing behind a wall; - - rows with no organization, only in a composition that registers no organization object. - - `SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts: - - the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller; - - the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults. - - These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader. - -No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action. diff --git a/.changeset/25325-execution-status-missed.md b/.changeset/25325-execution-status-missed.md deleted file mode 100644 index e9df86e60a6..00000000000 --- a/.changeset/25325-execution-status-missed.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-approvals': patch ---- - -feat: `ExecutionStatus` gains `missed`, the run status for a scheduled flow's due time that passed while no kernel was ticking - -Clause-②: yes (widening) -Metadata-protocol: none - -`ExecutionStatus` gains a tenth member, `missed`, appended last so every reader that indexes `.options` keeps its positions. It records a scheduled flow's due time that passed while no kernel was ticking, written once by the next kernel that finds the gap, one entry per missed due time. It is terminal: the run never started, is never resumed and is never replayed. It is distinct from `cancelled` (nobody cancelled it) and from `failed` (nothing ran). - -No field is added. On an `ExecutionLog`, `startedAt` is the due time and `completedAt` the detection time; `durationMs` and `refusalMessage` are absent and `steps` is empty. On the stored run row, `started_at` and `finished_at` carry the same two instants, and `duration_ms`, `error` and `refusal_message` are absent. - -`ListRunsRequestSchema.status` and the REST `?status=` filter read the enum, so both accept `missed`. `AutomationResult.status`, `TriggerFlowResponseSchema.data.status` and `JobExecutionStatus` do not change. - -`@objectstack/plugin-approvals`: `RUN_STATUS_LIVENESS` maps `missed` to `terminal`, so an approval pending on a missed run is swept like one pending on any other finished run. - -This release only declares the value. The engine writes `missed` rows in a later release. diff --git a/.changeset/25330-null-label-carry.md b/.changeset/25330-null-label-carry.md deleted file mode 100644 index 3b9d8cf24a8..00000000000 --- a/.changeset/25330-null-label-carry.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/service-analytics': patch -'@objectstack/platform-objects': patch ---- - -feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System" - -Clause-②: no -Metadata-protocol: none - -A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor: - -- **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes. -- **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before. -- **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System". diff --git a/.changeset/25331-admin-field-help.md b/.changeset/25331-admin-field-help.md deleted file mode 100644 index 895e700ca6f..00000000000 --- a/.changeset/25331-admin-field-help.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/metadata-core': patch -'@objectstack/plugin-security': patch -'@objectstack/plugin-approvals': patch -'@objectstack/service-messaging': patch -'@objectstack/objectql': patch ---- - -fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms - -Clause-②: no -Metadata-protocol: none - -A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): - -- the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; -- the permission set picked on an assignment; -- the start time of an approval delegation; -- the digest cadence and quiet hours of a notification preference, which now say how each value behaves; -- the AI Access and Locale fields of a user; -- the Data Lifecycle settings page and its retention overrides. - -`Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. diff --git a/.changeset/25332-setup-option-labels.md b/.changeset/25332-setup-option-labels.md deleted file mode 100644 index eb1f16437b0..00000000000 --- a/.changeset/25332-setup-option-labels.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/plugin-sharing': patch -'@objectstack/spec': patch ---- - -fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources - -Clause-②: no -Metadata-protocol: none - -- **`@objectstack/platform-objects`:** `sys_business_unit.kind` declared its options as bare strings, so each label equalled its stored value and the form offered `cost_center`. The options now carry labels (Company, Division, Department, Office, Cost Center). The stored values are unchanged. -- **`@objectstack/plugin-sharing`:** `sys_sharing_rule.recipient_type` and `access_level` declared bare-string options too. They now carry labels (User, Team, Business unit, Position, Business unit and subordinates, Field; Read, Read and edit), which are the names the Recipient Type help text already used. `sys_record_share` declares the same recipient and access vocabulary, so its `recipient_type` (User, Group, Position, Business unit and subordinates) and `access_level` options carry the same labels. The stored values are unchanged. -- **`@objectstack/spec`:** the `autoMigrate` key of the SQLite, PostgreSQL and MySQL driver configs had no JSON-Schema `title`, so the New datasource form labelled the field with the raw key `autoMigrate`. It is now titled "Auto-migrate schema". - -The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept. diff --git a/.changeset/25333-position-list-holders-and-grants.md b/.changeset/25333-position-list-holders-and-grants.md deleted file mode 100644 index 3b3de990fef..00000000000 --- a/.changeset/25333-position-list-holders-and-grants.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/core': patch -'@objectstack/plugin-security': patch -'@objectstack/rest': patch ---- - -feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both - -Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`) -Metadata-protocol: none - -Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it. - -- **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read. -- **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force. -- **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing. -- **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`. -- **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values. -- **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves. diff --git a/.changeset/25338-create-user-permission-set.md b/.changeset/25338-create-user-permission-set.md deleted file mode 100644 index db0a0685518..00000000000 --- a/.changeset/25338-create-user-permission-set.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/platform-objects': patch ---- - -feat: Create User can grant a permission set in the same step, judged by the same delegated-administration gate as Setup's grant form - -Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` and the `sys_user` `create_user` action accept an optional `permissionSet`; a request without it is answered exactly as before) -Metadata-protocol: none - -A user created with no permission set signed in to a screen of refusals. Setup → Users → Create User now offers an optional **Permission Set** (the set's machine name, checked against the security catalog). - -- **How the grant is written.** The route inserts the `sys_user_permission_set` row under the calling administrator's OWN execution context, never the system's. The security middleware, the ADR-0090 D12 delegated-administration gate (which stamps `granted_by`) and the catalog name check judge it exactly as they judge the grant form. The route's platform-admin gate decides who may create a user; it never decides what that user may be granted. -- **One step, one outcome.** The account and the grant run in one engine transaction. A refused grant answers the refusal's own status and code (403 `PERMISSION_DENIED` from the gate, 400 `VALIDATION_FAILED` with its `fields` for a name the catalog does not hold), and no user is created. A datasource whose driver has no `beginTransaction` runs the two writes unatomically, the engine's declared limit (ADR-0119 D1). -- **Refused before anything is written:** a `permissionSet` that is not a string (400), a caller whose own context does not resolve (403), and a host that wires no grant seam (501). -- **Response.** The success body carries `permissionSet` beside the user, and the result dialog shows it. -- **Cloud-managed environments are unchanged.** `create_user` stays hidden while `features.cloudManaged` is on. diff --git a/.changeset/25339-create-key-expiry.md b/.changeset/25339-create-key-expiry.md deleted file mode 100644 index d8fee1a200c..00000000000 --- a/.changeset/25339-create-key-expiry.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/runtime': patch ---- - -feat: Setup → API Keys → Create Key offers the key's expiry, and a date past the policy's maximum is refused with the policy named - -Clause-②: yes -Metadata-protocol: none - -Create Key asked only for a name, so every key silently got the default life the Authentication API key policy sets, and the list then showed an expiry the admin never chose. The `sys_api_key` action `create_api_key` now declares an optional `expires_at` param, the second body key `POST /api/v1/keys` already reads. Its help text says what an empty field means (the policy's Default key lifetime, 90 days unless changed) and that a date later than the policy's Maximum key lifetime is refused. The param has no literal default on purpose: an action param default is submitted verbatim, so a fixed date there would override the deployment's policy instead of following it. The reveal dialog now shows the expiry the key actually got, beside the key. - -The route behaviour is unchanged: an empty field gets the policy's default life, and a date past the maximum is refused `400 API_KEY_EXPIRY_TOO_LONG`, never clamped. That refusal's message now names the setting that decided it ("the Authentication API key policy's Maximum key lifetime is N days") next to the latest date allowed. diff --git a/.changeset/25343-session-heartbeat-default-config.md b/.changeset/25343-session-heartbeat-default-config.md deleted file mode 100644 index be570b0c022..00000000000 --- a/.changeset/25343-session-heartbeat-default-config.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Clause-②: no -Metadata-protocol: none - -The session activity heartbeat now runs on every deployment. `sys_session.last_activity_at` is "the last request on this session", and Setup → Sessions shows it so an administrator can tell two of one user's sessions apart. Until now it was written only while `sessionIdleTimeoutMinutes` was above 0, so on a default deployment (no idle or absolute limit) the Last Activity column stayed empty. - -It is still written at most once a minute per session. When neither the idle nor the absolute limit is configured, the `sys_session` read that feeds it is throttled too: a per-process memo, per session and bounded in size, skips the read for 60 seconds after one. With a limit configured, every request still reads the session row, as enforcement needs. diff --git a/.changeset/25343-session-list-columns.md b/.changeset/25343-session-list-columns.md deleted file mode 100644 index d888f261a4c..00000000000 --- a/.changeset/25343-session-list-columns.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix: Setup → Sessions shows each session's user agent and last activity, so two sessions of one user can be told apart - -Clause-②: no -Metadata-protocol: none - -Two sessions of one user read the same in the `sys_session` list: same user, organization and creation minute, with no device or activity column. An administrator who wanted to end one of them could not tell which, and revoked both. - -The two views that list live sessions, `all_sessions` (Setup's Sessions page) and `mine` (the Account app), now show `user_agent` and `last_activity_at` next to the IP address. Both fields already existed on `sys_session`. The `revoked` audit view is unchanged. - -`user_agent` is the raw string better-auth records. `last_activity_at` is written only while an idle timeout is configured (`session_idle_timeout_minutes` above 0). With the default of 0 the column is empty. diff --git a/.changeset/25364-last-admin-guard-read-fault-code.md b/.changeset/25364-last-admin-guard-read-fault-code.md deleted file mode 100644 index 49207d96d7e..00000000000 --- a/.changeset/25364-last-admin-guard-read-fault-code.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): the last-administrator guard answers a read it could not complete with 503 `SERVICE_UNAVAILABLE`, the code the sibling guards on the same doors answer their read faults with - -Clause-②: no -Metadata-protocol: none - -The last-administrator guard fails closed: when a read its verdict depends on throws, it refuses the write. That refusal answered 403 `PERMISSION_DENIED`, the same code as a refusal on the merits, so a caller could not tell "you may not" from "try again" by the code. The organization slug guard (`auth-manager.ts`) and the organization delete admission already answer their read faults with 503 `SERVICE_UNAVAILABLE`. - -- **Every half of the guard, one shape.** The shared fail-closed envelope and the Default Organization slug-capture rule now throw the same read fault: `code: 'SERVICE_UNAVAILABLE'`, `status: 503`, the read's own failure on `cause`. The write is still refused and nothing is written. A refusal on the merits, and an enumeration that completed but returned more rows than the guard can judge, keep 403 `PERMISSION_DENIED` unchanged. -- **The data API** answers the read fault 503 `SERVICE_UNAVAILABLE` with the server-fault message, as it does every declared 5xx. -- **better-auth's endpoints** (`organization/update`, the admin ban and remove-user endpoints, and every other endpoint that writes through the adapter) answer it 503 `SERVICE_UNAVAILABLE`. The adapter now maps any engine error that declares `status: 503` and `code: 'SERVICE_UNAVAILABLE'` to that answer; before, such an error reached better-auth's router as an unhandled 500 with an empty body. The driver's message is not relayed. -- **`organization/delete`** of the Default Organization answers the guard's read fault 503 `SERVICE_UNAVAILABLE`, the code the admission's own read faults answer with. A refusal on the merits there stays 403 `PERMISSION_DENIED`. -- **For a client:** branch on the code. `SERVICE_UNAVAILABLE` means retry once the data store is reachable; `PERMISSION_DENIED` means the write itself is refused. diff --git a/.changeset/25391-dataset-dimension-null-label.md b/.changeset/25391-dataset-dimension-null-label.md deleted file mode 100644 index 661dc7bc401..00000000000 --- a/.changeset/25391-dataset-dimension-null-label.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): a dataset dimension may name its null bucket with `nullLabel` - -Clause-②: yes (widening) -Metadata-protocol: added - -`DatasetDimensionSchema` gains an optional `nullLabel` (`I18nLabel`: a plain string or an inline locale map such as `{ en: 'System', 'zh-CN': '系统' }`). It names the bucket of rows whose value for that dimension is null, for example `'System'` on a `user_id` axis where the rows no user wrote carry no user. It is display copy only and changes no grouping. - -- **What is accepted now.** `dimensions[].nullLabel` on a dataset, in either label form. Every dimension that parsed before parses unchanged, and the dimension still refuses any other undeclared key. -- **What reads it.** Nothing yet. The chart renderer that draws the bucket under this label ships separately, and until it does the bucket keeps the renderer's own default label. The liveness ledger records the key as `planned`, so `os lint` and `os validate` warn when you set it. -- **What to do.** Nothing. No existing key, value or default changes. diff --git a/.changeset/console-0d12e95361b2.md b/.changeset/console-0d12e95361b2.md deleted file mode 100644 index 36e2e44ea81..00000000000 --- a/.changeset/console-0d12e95361b2.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Console (objectui) refreshed to `0d12e95361b2`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 2 releasing of 3 changesets added across 5 non-merge commits; omitted: 1 release-nothing changeset, 2 commits carrying no changeset (they ship no package code). - -- **minor** — An approver who is not an administrator sees the whole approval request page again: its fields, its decision panel with the decisions they may take, and its Timeline (objectui#133… (objectui `0d12e9536`) -- **patch** — Setup → Datasources offers "Sync objects" only on a datasource that can be synced (objectui#13318). (objectui `b8fa2237d`) - -**In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(pin): the missing-entry remedy builds with declarations whatever OS_SKIP_DTS is exported (#13325) (objectui `74ebad095`) -- _(no changeset)_ fix(hooks): port subst_end() into guard-main-checkout-bash (objectstack 7e40e8a03 and 338297e46) (#13320) (objectui `1aa88519a`) - -Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) - -objectui range: `cd14d8eeffe1...0d12e95361b2` diff --git a/.changeset/console-56cf4d8e89b7.md b/.changeset/console-56cf4d8e89b7.md deleted file mode 100644 index 3accbbc3532..00000000000 --- a/.changeset/console-56cf4d8e89b7.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Console (objectui) refreshed to `56cf4d8e89b7`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 16 releasing of 18 changesets added across 19 non-merge commits; omitted: 2 release-nothing changesets, 1 commit carrying no changeset (they ship no package code). - -- **minor** — The record page heading draws a `nameField` that declares a widget with a read face of its own through that face, the one the field's list column draws (objectui#13216). A record… (objectui `8701f1e76`) -- **patch** — The Import Wizard sends a CSV file to the import door as CSV text (objectui#13315). When you import a `.csv`, `.tsv` or `.txt` file, the request is now `format: 'csv'` with the ro… (objectui `56cf4d8e8`) -- **patch** — The console home blocks `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid` register under the `app-shell` namespace, so the console no longer logs a "without a namespace is… (objectui `7e9708e63`) -- **patch** — The `/data` page's generated filter bar now offers single-value relation fields (`lookup`, `master_detail` and `user`) beside the picklist fields (objectui#13310). A relation chip… (objectui `78bd6d732`) -- **patch** — The Position preview lists the permission sets a position declares in `permissionSets` (ADR-0131 D4), by machine name in declared order. The "positions don't grant data access on… (objectui `279425409`) -- **patch** — The console line for a visibility predicate that cannot be evaluated, on a gate that withholds what it gates, no longer says "a navigation entry" was withheld. The same line is pr… (objectui `601c406dd`) -- **patch** — A page node whose declared `visibleWhen` / `visible` / `visibleOn` predicate cannot be evaluated (over the CEL bounds, a type error, an unbound root, a missing function) is no lon… (objectui `3aafc0d53`) -- **patch** — A password or secret input now tells the browser what it is for (objectui#13295). (objectui `813c3d973`) -- **patch** — Settings pages: a `url`, `email`, `phone` or `color` value that misses the format its type declares now blocks Save (objectui#13287). No request goes out, the row is marked inline… (objectui `0093b2c82`) -- **patch** — A `page:tabs` item whose `visibleWhen` faults on evaluation is no longer drawn (objectui#13286). (objectui `7c405b73a`) -- **patch** — The inline-edit save bar shows an object-level rule's refusal as the rule's own message, without a field label (objectui#13292). (objectui `f66973232`) -- **patch** — data-table: with row numbers on, the header's select-all checkbox, each row's checkbox and the row number now share one centre line in the leading column (objectui#13293). (objectui `c8f8b10ad`) -- **patch** — data-table / VirtualGrid: selecting a cell's text no longer opens the record or starts an inline edit, and Ctrl/Cmd+C copies the selected text instead of the whole cell value. A p… (objectui `fbb4b23bf`) -- **patch** — The Approvals Inbox opens a request on the page the platform serves for it, and its side sheet is gone (objectui#12484). (objectui `a91f6bbb1`) -- **patch** — The package dialog's Dependencies row is now an author-facing editor (objectui#13278). It used to be the spec form's raw JSON box under the developer hint "widget object-fields —… (objectui `53dea27af`) -- **patch** — The app's report index (`/apps/APP/reports`) and the ⌘K palette's report list now include the reports a module contributes to the app's navigation (`navigationContributions`), not… (objectui `9e1bff63a`) - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ chore: release packages (#11600) (objectui `3aa2f5261`) - -Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) - -objectui range: `cf153eb2e535...56cf4d8e89b7` diff --git a/.changeset/console-5fef032b1ada.md b/.changeset/console-5fef032b1ada.md deleted file mode 100644 index 9c451377409..00000000000 --- a/.changeset/console-5fef032b1ada.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Console (objectui) refreshed to `5fef032b1ada`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 1 releasing of 2 changesets added across 2 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). - -- **minor** — A navigation entry's API `dataSource` and a page element bound to the same read now make one request per console open (objectui#13323). Before, `readElementApi` shared a request o… (objectui `b911ebb4e`) - -Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) - -objectui range: `0d12e95361b2...5fef032b1ada` diff --git a/.changeset/console-cd14d8eeffe1.md b/.changeset/console-cd14d8eeffe1.md deleted file mode 100644 index b80a4b382ca..00000000000 --- a/.changeset/console-cd14d8eeffe1.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Console (objectui) refreshed to `cd14d8eeffe1`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 1 releasing of 1 changeset added across 1 non-merge commit. - -- **patch** — The bulk action confirm step reads `confirmText` as a string or as a `{ dialect: 'template', source }` envelope (objectui#13183). (objectui `cd14d8eef`) - -Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) - -objectui range: `56cf4d8e89b7...cd14d8eeffe1` 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/.changeset/setup-nav-distinct-organization-labels.md b/.changeset/setup-nav-distinct-organization-labels.md deleted file mode 100644 index f96e187d571..00000000000 --- a/.changeset/setup-nav-distinct-organization-labels.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): Setup → People & Organization no longer shows two identical "组织" / "組織" entries - -Clause-②: no -Metadata-protocol: none - -`nav_organization` (the active organization's record page) and `nav_organizations` (the organization list, shown where `org-scoping` is mounted) both translated to "组织" in zh-CN and "組織" in ja-JP, so a multi-organization deployment showed two identical menu entries that open different pages. They now read "当前组织" / "所有组织" (zh-CN) and "現在の組織" / "すべての組織" (ja-JP). A new test fails when any two entries in one Setup nav group read the same in any shipped locale. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index e1f53f6a927..12c0a5dc445 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 089796798c8..3b582cdb6fc 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-20). +- [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-23). - [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 (final release: 17.7.0). - [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 9a42afaa237..c8aacc9d9c7 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..22f29b5c26d 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/example-crm +## 4.0.101 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [fb7501b] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [c898bd8] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/runtime@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..4fb65e6c51f 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,63 @@ # @objectstack/example-multi-package +## 0.0.8 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..c4422a4d4be 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,103 @@ # @objectstack/example-showcase +## 0.3.23 + +### Patch Changes + +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [72a302a] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [ea08ce5] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [b811c73] +- Updated dependencies [5d27d32] +- Updated dependencies [2069b81] +- Updated dependencies [5d66021] +- 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 [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [8c73398] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [ae58b26] +- Updated dependencies [c71d03a] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [fb7501b] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [c898bd8] +- Updated dependencies [7c68b10] + - @objectstack/cloud-connection@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/runtime@18.1.0 + - @objectstack/driver-sql@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..306d8314442 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,99 @@ # @objectstack/example-todo +## 4.0.101 + +### Patch Changes + +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [9340181] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [8010b64] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [72fad92] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [c898bd8] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/runtime@18.1.0 + - @objectstack/objectql@18.1.0 + - @objectstack/driver-sqlite-wasm@18.1.0 + - @objectstack/mcp@18.1.0 + - @objectstack/client@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..2fb5d08969e 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,80 @@ # @objectstack/example-embed-objectql +## 0.0.41 + +### Patch Changes + +- Updated dependencies [448374d] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [9340181] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..ae1c6978278 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,44 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) a narrowing of CORS admission at three doors plus a boot refusal of one more configuration class: no authorable metadata key, spelling, type or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. The remedy is a deployment variable or the door's own origin option, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is an admission verdict, not an interface, a type or an export retirement (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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] +- Updated dependencies [9cfc575] +- Updated dependencies [a1e51d6] +- Updated dependencies [fb7501b] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [eee2f0d] +- Updated dependencies [c898bd8] + - @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..f7afd05d636 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,82 @@ # @objectstack/account +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @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..0e46d8897ef 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,82 @@ # @objectstack/setup +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @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..b101ceff6da 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,82 @@ # @objectstack/studio +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @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..714fd774353 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,685 @@ # @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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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__'`). +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. +- 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) + + <!-- adr-0087: registered sys-notification-subscription-retired --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the exit status one CLI command returns when it applied nothing because the confirmation it asks for was not given; the remedy is a flag on the caller's command line (`--yes`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 <path>`.** 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.<page>.components.<tabsId>.items.<value>.label` + + Clause-②: yes (widening) + + - **Before.** `pages.<page>.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.<page>.components.<tabsId>.items.<value>.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.<object>._views.<view>.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.<view>` 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.<object>._views.<view>.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.<key>` 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.<view>.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 + +- 448374d: feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) + + Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening). + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. --> + + **BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. + + - **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. + - **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. + - **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. + - **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. + - **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). + - **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. + - **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. + + The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. +- 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. +- 2a2f5c2: fix(cli): `os lint` and `os i18n check` ask for an approval flow's label and each approval step's label (#24916) + + Clause-②: no + Metadata-protocol: none + + The approvals door names a request's flow and its current step in the reader's locale, from `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label`. The i18n extractor asked for `flows.FLOW.label` only on a flow with a `screen` node, and for no approval step at all, so `i18n/missing-flow` never listed an approval flow or its steps as untranslated, and `os i18n extract` never scaffolded those keys. An app's approvals inbox could show the authoring language in every other locale under a green coverage run. + + Now: + + - `flows.FLOW.label` is asked for on a flow with a `screen` node or an `approval` node, the two surfaces that show it. A flow with neither, such as a record-triggered or scheduled flow that only does work, still owes no label, because nothing shows it. + - `flows.FLOW.approvals.NODE.label` is asked for on every `approval` node that has an `id`, seeded with the node's own `label`. + - Both are reported as `i18n/missing-flow` by `os lint`, listed by `os i18n check`, and written into the skeleton by `os i18n extract`. `os i18n check --help` names approval steps among the surfaces it covers. + + **What you may see.** An app that ships an approval flow and declares a locale beyond its default now gets an `i18n/missing-flow` finding for each untranslated approval flow and step. Under `os lint --i18n-strict` or `os i18n check --strict` these are errors, like every other missing translation. To clear them, translate the keys in that locale's pack, for example: + + ```ts + flows: { + expense_approval: { + label: '报销审批', + approvals: { manager_review: { label: '经理审核' } }, + }, + }, + ``` + + `os i18n extract` writes the empty slots for you and keeps the strings a pack already translates. No key, schema or export changes: both keys were already part of the translation contract, and `os validate` already accepted them. +- 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`. +- 68a57ad: fix(cli): `os lint` and `os i18n check` ask for the label of a navigation item another package contributes into an app (#24993) + + Clause-②: no + Metadata-protocol: none + + A package can add items to an app it does not own through `manifest.navigationContributions`. The runtime merges those items into the app's navigation and shows each one's label from `apps.APP.navigation.ID.label`, where `APP` is the app the item is contributed to. `os validate` already accepted that key. But the i18n extractor only read each app's own `navigation`, so a contributed item's key was never expected: deleting its translation left `os lint --strict` and `os i18n check` green. The same deletion on an item the app declares itself was reported. + + Now a contributed item is expected under its target app, from either place a contribution is declared: the stack's own `manifest`, or a package's manifest inside a `composeStacks(…, { manifest: 'preserve' })` artifact. Its source text is its own `label`. A contributed group's children are expected as an app's own group children are. An item the app also declares keeps the app's entry, and a contribution read from two places is expected once. + + **What you may see.** A project that contributes navigation and declares a locale beyond its default now gets an `i18n/missing-navigation` finding for each contributed item that locale's pack does not translate. It is a warning by default, which `os lint --strict` fails on. Under `os lint --i18n-strict` or `os i18n check --strict` it is an error, like every other missing translation. To clear them, translate the key under the target app in that locale's pack, for example: + + ```ts + apps: { + crm_enterprise: { + navigation: { nav_case: { label: '工单' } }, + }, + }, + ``` + + `os i18n extract` now writes these slots too, and keeps the strings a pack already translates. No key, schema or export changes: the key was already part of the translation contract. +- 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. +- 8c73398: fix(driver-sql,cli): `os migrate plan` / `apply` no longer offer `--allow-destructive` for a unique index the existing rows already violate + + Clause-②: yes (widening) + Metadata-protocol: none + + The duplicate pre-flight that blocks such a `create_index` (ADR-0120 D4) said so only in the entry's message, and the apply-limit seam the two commands read asked the driver by op type. So `os migrate plan` closed with `(add --allow-destructive for drops / tightenings)`, `os migrate apply --yes` said `re-run with --allow-destructive to include them`, and that re-run refused the entry and ended PARTIAL. + + - **`ManagedDriftEntry.applyLimit` (`@objectstack/driver-sql`, new and optional).** Why the reporting driver's apply door will not perform this entry on this database, read from its data. The duplicate pre-flight sets it on a blocked unique index, naming the conflicting groups. It is absent on every other entry, so a reader that ignores it sees the report as before. On `--json`, a blocked entry now carries this key. + - **`os migrate plan` / `os migrate apply` (`@objectstack/cli`).** The apply-limit seam reads the entry's own limit first, then the driver's per-op one. A blocked unique index is listed with the conflicting groups as the reason it cannot be applied, the footer and the skip lines stop offering `--allow-destructive` for it, and it is counted among the changes that remain drift. A database with no blocked index prints as before. + + Unchanged: `os migrate apply --allow-destructive` still names such an entry as not applied and reports the run PARTIAL with exit 1, because the database still differs from its metadata. To clear it, deduplicate the listed rows and run `os migrate plan` again. +- 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 + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration of this package is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the vendor library under the auth endpoints: some requests it accepted are now refused, and some refusals change status. The remedies are operational (restart an in-flight sign-in, correct an SSO provider's issuer), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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". +- 80ec71d: fix(cli): retire the scaffold's five stale `peerDependencyRules` entries on the better-auth 1.7.12 pin + + Clause-②: no + Metadata-protocol: none + + A project scaffolded by `objectstack init` or `npx create-objectstack` no longer carries a `peerDependencyRules.allowedVersions` block in its `pnpm-workspace.yaml`. All five entries suppressed peer mismatches that better-auth 1.7.12 no longer has: `better-auth` declares no `better-sqlite3` peer, and `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim` and `@better-auth/sso` each peer `@better-auth/utils` as `^0.4.2 || ^0.5.0`, which the resolved 0.5.0 satisfies. `allowedVersions` only silences a report, so a scaffold resolves the same lockfile as before and its first `pnpm install` stays free of unmet-peer warnings. Projects scaffolded earlier can keep or delete the block; either way nothing changes. +- ab488d9: fix(cli)!: a production `os serve` that composes no organization object is refused whether or not `--server`, so `--no-server` no longer writes ownerless rows + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) A boot-time refusal in `os serve`: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is removed or renamed; `--no-server` keeps its meaning (no HTTP server plugin) and only stops being a way round the auth refusal. The remedy is a deployment one, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** boot-refusal narrowing (ADR-0131 D1, "NULL is not a state", and D9). It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. + + - FROM: a production `os serve --no-server` (no `--dev`; `os start` has no `--no-server` flag) booted the kernel when the stack's `auth` tier was on and no auth secret resolved, or when the stack mounted no auth at all (the `auth` tier off, `--preset minimal` included, with no `AuthPlugin` of its own), warning `⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`. Neither boot composes an organization object, so every row it wrote, its inline seed first, landed with no organization. Measured on an 18.0.0-born store: a deleted seed row was written back with `organization_id` NULL. On a fresh store the seed failed instead, every write refused by the NOT NULL tenant column. Both refusal texts named "run with --no-server" as a remedy. + - TO: both boots are refused whether or not `--server`, before any plugin starts. That includes a production `os serve --preset minimal`: the minimal preset composes no `auth` tier, so unless the stack mounts `AuthPlugin` itself it is refused, kernel-only or not (with `--server` it already was), so nothing is written and the database is never opened. That covers a `--no-server` migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`), which runs the same kernel bootstrap. Neither refusal text offers `--no-server` any more: each says a kernel-only boot does not avoid it. + + **Unchanged:** `os dev` and `--dev` (a development boot resolves its fallback secret, and a no-auth development boot is still refused only when it serves); an embedded kernel (`createStandaloneStack`, `@objectstack/objectql/core`), which never runs this command; and the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's), which keeps its warn-only path. `os start` always hands its `serve` child an auth secret, so it reaches the no-auth case only. + + **The remedy.** + - Refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes, e.g. `OS_AUTH_SECRET=$(openssl rand -hex 32)`). + - Refused because the stack mounts no auth: enable auth (add the `auth` tier, or mount `AuthPlugin` in `plugins`) and set `OS_AUTH_SECRET`. Declaring the stack's own objects `tenancy: { enabled: false }` does not lift this refusal: the platform's own system objects are tenant-scoped, so a production stack with no organization object would still write ownerless rows. + - A cloud migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`) must carry `OS_AUTH_SECRET` in production; without it the job is now refused, naming the variable. + + **Release-notes correction.** The 18.0 release page says a production `os serve` with no auth secret can "serve without the data API (`--no-server`)". That remedy is withdrawn: `--no-server` is refused the same way. The pending changeset `24583-7b3-organizationless-counter-retired` is corrected in this change to match. +- 6251f59: feat(cli): `os migrate plan` lists the tenant columns still nullable and names the voluntary organization-ownership ceremony; the ceremony reports a rotation shard already NOT NULL as such (#25134) + + Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves. + + Metadata-protocol: none + + A database the published 18.0.0 created is certified at birth with its tenant columns (`organization_id`) nullable, and `os migrate organization-ownership`, which tightens them to `NOT NULL`, is voluntary on it. Nothing told the operator that. + + - **`os migrate plan`: a new advisory section.** It lists every tenant column the database still holds nullable (table and column) and names `os migrate organization-ownership` as the remedy. Each column says whether the ceremony tightens it or leaves it, and why. The ceremony leaves a rotation shard created before shards were created `NOT NULL`, `sys_email_template` (its rows are mirrors the ceremony deletes, and the table retires), a table its inventory exempts, and every table on a database whose transport cannot rebuild a table (the Turso remote face). When the ceremony would tighten none of the listed columns, the section says so and does not offer it. A fresh install lists nothing. A column named `organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a tenant column and is not listed. + - **Advisory only.** It is not drift: it proposes no change, adds nothing to `total` or `changes`, and the exit code does not change. Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr, remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }` when the plan could not read it. + - **The ceremony's `not-null` step.** A rotation shard that the driver created `NOT NULL` used to be reported as `unsupported`, with a note saying each new shard is created nullable. That has not been true since the driver began creating shards `NOT NULL`. Such a shard is now `already-not-null` (counted in `alreadyNotNull`) and gets no note. A shard rotated in before then stays `unsupported`, and its note says it is dropped when its period leaves the base table's retention. + + **What to do.** Nothing is required: the server starts either way. To tighten the columns the plan lists, run `os migrate organization-ownership --out PLAN`, review it, then run `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`. +- b2e964f: fix(cli)!: a production `os serve` whose `@objectstack/plugin-auth` fails to load is refused, naming the load error, instead of booting with no organization object + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) A boot-time refusal in `os serve`: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is removed or renamed. The remedy is a deployment one (repair the install), stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** boot-refusal narrowing (ADR-0131 D9), the third composition beside the two the previous change refused. It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. + + - FROM: a production `os serve` or `os start` (no `--dev`) whose stack enables the `auth` tier and resolves an auth secret, but whose `@objectstack/plugin-auth` fails to load — a missing install (silently) or a broken one (one `⚠ AuthPlugin failed to load: …` warning) — booted with no `AuthPlugin`. It composed no organization object, so every row the boot wrote landed with no organization: stored ownerless on an 18.0.0-born store (nullable columns, MongoDB), refused at NOT NULL on a fresh SQL table. + - TO: that boot is refused before any plugin starts, with or without `--server`, and the database is never opened. The refusal names the load error. The same holds when the AuthPlugin construction itself throws before it is mounted. + + **Unchanged:** `os dev` and `--dev` (a development boot keeps the warning and boots without auth); the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`), whose auth is each project's; a production boot with no auth secret or with no auth at all, which is still refused by the existing refusals; and an embedded kernel, which never runs this command. + + **The remedy.** Repair the install the error names: `@objectstack/plugin-auth` is a dependency of `@objectstack/cli`, so reinstall the deployment's dependencies (e.g. `npm ci` or `pnpm install --frozen-lockfile`). Running kernel-only (`--no-server`) does not avoid the refusal. +- b6e8707: fix(cli,create-objectstack): new projects declare vitest `~4.1.12`, outside GHSA-qxg6-89mg-rqv8, and still install on npm 10 + + Clause-②: no + Metadata-protocol: none + + The vitest range every scaffold declares moves from `~4.0.0` to `~4.1.12`: the `create-objectstack` blank template, `os init -t plugin` and `os create plugin` (in `devDependencies`), and the npm `overrides` block that every standalone scaffold emits, `os init -t app` and `-t empty` included. `~4.0.0` resolved to vitest and `@vitest/mocker` 4.0.18, inside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12), which has no 4.0.x fix. A new project now resolves vitest and `@vitest/mocker` 4.1.12 or a later 4.1.x patch, and `npm audit` of a fresh scaffold lists no vitest or `@vitest/*` advisory. + + npm 10, the npm Node 22 bundles, still installs every scaffold. The `overrides.vitest` entry, which equals `devDependencies.vitest`, is what makes that hold: it settles vitest 4.1.12's contradictory optional peer (`@vitest/browser-webdriverio`), which npm 10 otherwise crashes on with `Cannot read properties of null (reading 'edgesOut')`. Measured on npm 10.9.4, 10.9.9 and 11.21.1. pnpm ignores npm's `overrides` field. + + **An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`** keeps its own `~4.0.0`. To move it, set `overrides.vitest` to `~4.1.12` in `package.json`, and `devDependencies.vitest` too where the project declares it, then run `npm install`. npm refuses an override that differs from the direct dependency (`EOVERRIDE`), so the two move together. +- d0c56a2: fix(cli,create-objectstack): new projects override `uuid` below 12.0.0 to `^11.1.1`, so `npm audit` lists no GHSA-w5hq-g745-h8pq entry + + Clause-②: no + Metadata-protocol: none + + `@objectstack/rest` depends on `exceljs ^4.4.0`, and exceljs 4.4.0, its newest release, depends on `uuid ^8.3.0`. GHSA-w5hq-g745-h8pq affects `uuid` below 11.1.1, so `npm audit` in a fresh npm-installed scaffold reported 9 moderate entries, all from that one advisory. The npm `overrides` block every standalone scaffold emits now carries `"uuid@<12.0.0": "^11.1.1"`: the `create-objectstack` blank template, `os init` (`-t app`, `-t plugin`, `-t empty`) and `os create plugin`. A new project resolves `uuid` 11.1.1 and `npm audit` lists no `uuid` entry, measured on npm 10.9.9 and 11.21.1. exceljs calls only `uuid`'s `v4()`, which `uuid` 11 keeps; exceljs's buffered and streaming writers, its loader and `@objectstack/rest`'s `parseXlsxToRows` were run on the installed tree. pnpm ignores npm's `overrides` field. + + The selector stops at `<12.0.0`, so a project can still add a newer `uuid` of its own: `npm install uuid@14` installs. A spec-less `npm install uuid` is refused with `EOVERRIDE` (npm reads it as `uuid@*`, which meets the selector); name the major instead. + + **An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`**, or any other npm project that installs `@objectstack/rest`, directly or through `@objectstack/runtime`, `@objectstack/cli`, `@objectstack/plugin-auth` or `@objectstack/verify`: add `"uuid@<12.0.0": "^11.1.1"` to the `overrides` block of its root `package.json` and run `npm install`. npm reads `overrides` only from the root manifest, so no `@objectstack/*` release can apply it for you. +- 6bc8cc6: fix(cli): `os create plugin` ships a starter test, so a new project's `npm test` passes + + Clause-②: no + Metadata-protocol: none + + A project made by `os create plugin NAME` declared `"test": "vitest"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `src/index.test.ts`, which runs the checks the kernel runs on a plugin when it loads one: a `name`, an `init` function, and the protocol's `PluginSchema`. It boots no kernel; its header links the `@objectstack/verify` harness for tests that need one. The `test` script is now `vitest run`, one pass that exits. + + The starter test lives in `src/`, which `tsc` compiles, so the template also writes `tsconfig.build.json`: it extends `tsconfig.json` and excludes `src/**/*.test.ts`, and `build` and `dev` now run `tsc -p tsconfig.build.json`. `typecheck` still reads `tsconfig.json`, so a type error in a test still fails it, while `dist/` holds no compiled test and `npm test` after `npm run build` runs each test once. + + **An existing project made by an earlier `os create plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `src/index.test.ts` that a new `os create plugin NAME` writes. If you put it under `src/`, also copy `tsconfig.build.json` and point the `build` and `dev` scripts at it (`tsc -p tsconfig.build.json`), or `tsc` compiles the test into `dist/` and vitest runs it twice. +- 9c431ba: fix(cli): `os init -t plugin` ships a starter test, so a new project's `npm test` passes + + Clause-②: no + Metadata-protocol: none + + A project made by `os init NAME -t plugin` declared `"test": "vitest run"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `package.test.ts` beside `objectstack.config.ts`. The test imports the package's stack, which runs `defineStack()` and its protocol check as the file loads, and asserts that the stack declares itself a `plugin` and contains every object `src/objects/index.ts` exports. A fresh project's `npm test` reports `Test Files 1 passed`. The test boots no engine. Its header links the `@objectstack/verify` harness for tests that need one. + + **An existing project made by an earlier `os init -t plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `package.test.ts` that a new `os init NAME -t plugin` writes. +- 24360dc: fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. + - **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. + - **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". + - **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. +- 1eb1afa: feat(cli): the ownership ceremony gives a walled organization-less self-registration grant its user's sole membership's organization + + Clause-②: no + Metadata-protocol: none + + Before the self-registration grant waited for the registrant's first membership, a sign-up under `isolated` or `group` wrote the grant (`sys_user_permission_set`) with no organization. `os migrate organization-ownership` reported every such row as unattributable, so each one held certification until the operator assigned its organization by hand. + + Under a wall the ceremony now derives the owner when the user's memberships (`sys_member`) all name one organization. That is where the runtime settles the grant today. The plan counts these rows under `sole-membership:user_id->sys_member`, and `--apply` writes them. + + A grant stays reported, never guessed and never deleted, in three cases: + + - its user is a member of several organizations, because which membership came first is not recorded; + - its user is a member of none; + - its user already holds the same permission set in that organization, because the grant's unique index would refuse the write. + + Each reported row's reason names its case. The remedy is unchanged: assign the organization, or delete the row by hand, then re-run `--apply`. Under `single` nothing changes, and the Default Organization takes every organization-less grant. +- 8dfd126: fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The narrowing is one more organization write the last-administrator guard refuses at runtime. --> + + **BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. + + - **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. + - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. + - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. + - **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. + + **What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. +- 7b047b4: feat(cli): the console document carries the runtime branding in its `<title>`, description and Open Graph tags, written on the server, so a link preview shows the product's name + + Clause-②: no + Metadata-protocol: none + + `createConsoleStaticPlugin` (what `os serve` mounts at `/_console/`) used to serve the console shell with the empty `<title>` it ships, and no description or `og:*` tags. Only the shell's inline script set the title, in the browser, from `GET /api/v1/runtime/config`. A link-preview crawler or `curl` runs no script, so it saw a blank title. + + Every console HTML answer (the shell and the SPA fallback) now reads the branding from that same door, in-process and for the same request, and writes: + + - `<title>` and `og:title` from `branding.productName`, the name the script sets as the tab title; + - `<meta name="description">` and `og:description` from `branding.pwaDescription`; + - `og:image` from `branding.logoUrl`, resolved against the request origin; + - `og:url`, the request origin (its scheme from `x-forwarded-proto` when a proxy states `http` or `https`). + + Every value is HTML-escaped, and a missing value writes no tag. A composition that mounts no `runtime/config` door gets the document exactly as before. A branding read that fails serves the document as before and logs one `warn`. + + Nothing to change: `OS_PRODUCT_NAME`, `OS_PWA_DESCRIPTION`, `OS_LOGO_URL`, the `RuntimeConfigPlugin` options and, under `single`, a saved Setup → Branding name and logo reach the tags the way they reach the console. +- Updated dependencies [54df640] +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [72a302a] +- Updated dependencies [fcb40ad] +- Updated dependencies [ba69b16] +- Updated dependencies [865d2c8] +- Updated dependencies [865d2c8] +- Updated dependencies [2941a19] +- Updated dependencies [3b3830c] +- Updated dependencies [abc25a3] +- Updated dependencies [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [3d8960c] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [ea08ce5] +- Updated dependencies [19e5338] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0603bfa] +- Updated dependencies [0b8bce4] +- 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 [2069b81] +- Updated dependencies [0a3ce08] +- Updated dependencies [5d66021] +- Updated dependencies [c5915b2] +- 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 [42b60b9] +- 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 [c24203f] +- Updated dependencies [d6a5fa3] +- Updated dependencies [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [4bf079c] +- Updated dependencies [8c73398] +- Updated dependencies [0f1eeee] +- Updated dependencies [f46ef4d] +- Updated dependencies [3782b2d] +- Updated dependencies [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [a39389e] +- Updated dependencies [9340181] +- Updated dependencies [9340181] +- Updated dependencies [2e242bb] +- Updated dependencies [fccbd9d] +- Updated dependencies [459afea] +- Updated dependencies [c8f4c43] +- Updated dependencies [4f97aeb] +- Updated dependencies [c4b49c3] +- Updated dependencies [db9c02a] +- Updated dependencies [284e048] +- Updated dependencies [c6bdcac] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [0148071] +- Updated dependencies [03966ce] +- Updated dependencies [ae58b26] +- Updated dependencies [1b3b65e] +- Updated dependencies [331613c] +- Updated dependencies [5801a4f] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [c71d03a] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [80ec71d] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [8010b64] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [b6e8707] +- Updated dependencies [fe0ec2b] +- Updated dependencies [d0c56a2] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [1f78b64] +- Updated dependencies [bc5daf0] +- Updated dependencies [3a440b0] +- Updated dependencies [279fdcf] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [17f3c65] +- Updated dependencies [b62f959] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [72fad92] +- Updated dependencies [d196748] +- Updated dependencies [9d60b42] +- Updated dependencies [829cb77] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [69913f8] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [431327b] +- Updated dependencies [97aa34e] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] +- Updated dependencies [cc8d886] +- Updated dependencies [06900c9] +- Updated dependencies [9a48ae8] +- Updated dependencies [9057fd4] +- Updated dependencies [7adfaec] +- Updated dependencies [f7d7403] + - @objectstack/plugin-security@18.1.0 + - @objectstack/plugin-sharing@18.1.0 + - @objectstack/cloud-connection@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/runtime@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/driver-memory@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/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/service-analytics@18.1.0 + - @objectstack/rest@18.1.0 + - @objectstack/driver-sqlite-wasm@18.1.0 + - @objectstack/plugin-pinyin-search@18.1.0 + - create-objectstack@18.1.0 + - @objectstack/mcp@18.1.0 + - @objectstack/formula@18.1.0 + - @objectstack/console@18.1.0 + - @objectstack/verify@18.1.0 + - @objectstack/account@18.1.0 + - @objectstack/setup@18.1.0 + - @objectstack/client@18.1.0 + - @objectstack/driver-mongodb@18.1.0 + - @objectstack/metadata@18.1.0 + - @objectstack/observability@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/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..0b3076f6917 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/client-react +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..74816565f31 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/client +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..bb3ff8c0a88 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,180 @@ # @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 capability 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. + + Clause-②: no (narrowing) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is what a runtime holding the environment service key serves: it no longer registers the Cloud Connection page or its navigation entry, and it refuses a disconnect it used to carry out. The navigation entry's new group is a value inside an exported constant whose type is unchanged. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 and the permission its connect and disconnect requests and its connection details require, which another change in this release raises to `manage_platform_settings`. The connection status answer is otherwise 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 + +- 448374d: feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) + + Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening). + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. --> + + **BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. + + - **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. + - **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. + - **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. + - **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. + - **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). + - **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. + - **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. + + The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. +- c71d03a: fix(cloud-connection)!: one rule for the Cloud Connection capability — the page's record and its three connection-changing requests require `manage_platform_settings`, like its Setup entry; a server holding the environment service key also refuses `bind/start` and `bind/poll` + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which signed-in callers the Cloud Connection routes serve: the capability they read moves from `setup.access` to `manage_platform_settings`, and a server holding the environment service key refuses two more of them. The navigation entry's added `requiredPermissions` is a value inside an exported constant whose type is unchanged. --> + + **BREAKING** (a signed-in user who holds Setup Access, `setup.access`, without Manage Platform Settings, `manage_platform_settings`, such as an organization administrator under the default permission sets, can no longer connect or disconnect the server, and is no longer served the connection's details), shipped as `patch`: the change is to which callers the routes serve, not to the metadata protocol. + + The Cloud Connection page's Setup entry sits in the Integrations group, which requires `manage_platform_settings`. The page's routes still took `setup.access` alone, so an organization administrator, whose default permission set carries Setup Access but not Manage Platform Settings, was not shown the entry but could open the page by its address and connect or disconnect the server. One rule now covers the entry, the page and the routes: + + - **Refused now:** `POST /api/v1/cloud-connection/bind/start`, `POST /api/v1/cloud-connection/bind/poll` and `POST /api/v1/cloud-connection/unbind` answer a signed-in user who does not hold `manage_platform_settings` with `403` and the standard `PERMISSION_DENIED` code, and the message names that capability. Holding `setup.access` is no longer enough. The refusal still comes before any call to the cloud and before the stored credential is touched. + - **Narrowed now:** `GET /api/v1/cloud-connection/status` serves `environmentId` and `connection` only to a user who holds `manage_platform_settings`. Every other caller, an organization administrator included, is served `bound`, `runtimeId` and `provider`, as an anonymous caller is. + - **Declared on the entry:** the Cloud Connection navigation entry now declares `requiredPermissions: ['manage_platform_settings']` itself, the capability its group already required, so the entry and the routes read one rule from this package. The page carries no permission key of its own (a page has none); what it shows is served by these routes. + - **Refused on a platform-managed server:** a server holding the environment service key (`controlPlaneApiKey`, or `OS_CLOUD_API_KEY`) already refused `unbind` with `409` and `RESOURCE_CONFLICT`. It now refuses `bind/start` and `bind/poll` the same way, after the sign-in and capability checks and before any call to the cloud or any write to the stored credential. A device connection completed there used to store a credential the service key always overrode, while presenting that key to the cloud, which could move the environment's cloud-side connection record. The message now says the connection cannot be connected or disconnected here, and that nothing was sent to the cloud and nothing was stored or cleared. + - **Unchanged:** a user who holds `manage_platform_settings`, which the default platform-administrator permission set carries, is served exactly as before on a server without the service key. Anonymous callers still get `401`, and a session the authentication policy is holding still gets its `403`. The device-code flow's steps, the status answer for a permitted caller, and the four other session routes (`install`, `installation`, `installed`, `org-packages`) are unchanged. + + **Who acts:** a deployment that relies on organization administrators, or another set holding only Setup Access, to connect its server grants `manage_platform_settings` to those users, through a permission set or a position, and they are served as before. +- db1c7cc: feat(core): `isCloudManaged()` is the one reader of `OS_CLOUD_MANAGED`, and a production boot that sets it without a bound control plane refuses to start + + Clause-②: yes (widening) + Metadata-protocol: none + + - **New exports of `@objectstack/core`:** `isCloudManaged()`, `resolveControlPlaneApiKey(configured?)` and `resetCloudManagedForTesting()`. `isCloudManaged()` is synchronous and answers `true` when `OS_CLOUD_MANAGED` is exactly `1`, `false` when it is unset or empty; any other value is refused with an error that names it. The answer is memoized for the process; tests that set the variable call `resetCloudManagedForTesting()` before and after. + - **The fuse.** On a production boot (not `--dev`, which sets `NODE_ENV=development` only when `NODE_ENV` is unset, and not `NODE_ENV=test`; so `NODE_ENV=production os serve --dev` counts as a production boot and is refused the same way), `OS_CLOUD_MANAGED=1` with `OS_CLOUD_API_KEY` unset or empty refuses: `ObjectKernel.bootstrap()` and `LiteKernel.bootstrap()` reject before any plugin initialises, and `isCloudManaged()` throws the same error. The cloud sets both variables on the runtimes it hosts. A self-hosted deployment that has `OS_CLOUD_MANAGED=1` in a copied env file now fails to boot; remove the variable, which a self-hosted deployment never needs. + - **One caveat:** the fuse reads the environment only. A runtime that binds its control plane solely through `CloudConnectionPlugin`'s `controlPlaneApiKey` option, and also sets `OS_CLOUD_MANAGED=1` in production, is refused. Set `OS_CLOUD_API_KEY` instead. + - **`@objectstack/cloud-connection`:** the plugin resolves its control-plane credential through `resolveControlPlaneApiKey(controlPlaneApiKey)`, so the plugin's "platform-managed" answer and the fuse share one derivation. Its behaviour is unchanged: the option wins, then `OS_CLOUD_API_KEY`, trimmed. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [9340181] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [17f3c65] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [69913f8] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [c898bd8] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/runtime@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 33cdaf94652..4a8656800ff 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..b4fa4a89f50 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/connector-mcp +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..8771925244a 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/connector-openapi +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..04d3c02e7a3 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/connector-rest +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..d180b4f895b 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/connector-slack +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..74582138831 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,151 @@ # @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` + +### Patch Changes + +- cc8d886: Console (objectui) refreshed to `0d12e95361b2`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 2 releasing of 3 changesets added across 5 non-merge commits; omitted: 1 release-nothing changeset, 2 commits carrying no changeset (they ship no package code). + + - **minor** — An approver who is not an administrator sees the whole approval request page again: its fields, its decision panel with the decisions they may take, and its Timeline (objectui#133… (objectui `0d12e9536`) + - **patch** — Setup → Datasources offers "Sync objects" only on a datasource that can be synced (objectui#13318). (objectui `b8fa2237d`) + + **In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(pin): the missing-entry remedy builds with declarations whatever OS_SKIP_DTS is exported (#13325) (objectui `74ebad095`) + - _(no changeset)_ fix(hooks): port subst_end() into guard-main-checkout-bash (objectstack 7e40e8a03 and 338297e46) (#13320) (objectui `1aa88519a`) + + Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) + + objectui range: `cd14d8eeffe1...0d12e95361b2` +- 06900c9: Console (objectui) refreshed to `56cf4d8e89b7`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 16 releasing of 18 changesets added across 19 non-merge commits; omitted: 2 release-nothing changesets, 1 commit carrying no changeset (they ship no package code). + + - **minor** — The record page heading draws a `nameField` that declares a widget with a read face of its own through that face, the one the field's list column draws (objectui#13216). A record… (objectui `8701f1e76`) + - **patch** — The Import Wizard sends a CSV file to the import door as CSV text (objectui#13315). When you import a `.csv`, `.tsv` or `.txt` file, the request is now `format: 'csv'` with the ro… (objectui `56cf4d8e8`) + - **patch** — The console home blocks `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid` register under the `app-shell` namespace, so the console no longer logs a "without a namespace is… (objectui `7e9708e63`) + - **patch** — The `/data` page's generated filter bar now offers single-value relation fields (`lookup`, `master_detail` and `user`) beside the picklist fields (objectui#13310). A relation chip… (objectui `78bd6d732`) + - **patch** — The Position preview lists the permission sets a position declares in `permissionSets` (ADR-0131 D4), by machine name in declared order. The "positions don't grant data access on… (objectui `279425409`) + - **patch** — The console line for a visibility predicate that cannot be evaluated, on a gate that withholds what it gates, no longer says "a navigation entry" was withheld. The same line is pr… (objectui `601c406dd`) + - **patch** — A page node whose declared `visibleWhen` / `visible` / `visibleOn` predicate cannot be evaluated (over the CEL bounds, a type error, an unbound root, a missing function) is no lon… (objectui `3aafc0d53`) + - **patch** — A password or secret input now tells the browser what it is for (objectui#13295). (objectui `813c3d973`) + - **patch** — Settings pages: a `url`, `email`, `phone` or `color` value that misses the format its type declares now blocks Save (objectui#13287). No request goes out, the row is marked inline… (objectui `0093b2c82`) + - **patch** — A `page:tabs` item whose `visibleWhen` faults on evaluation is no longer drawn (objectui#13286). (objectui `7c405b73a`) + - **patch** — The inline-edit save bar shows an object-level rule's refusal as the rule's own message, without a field label (objectui#13292). (objectui `f66973232`) + - **patch** — data-table: with row numbers on, the header's select-all checkbox, each row's checkbox and the row number now share one centre line in the leading column (objectui#13293). (objectui `c8f8b10ad`) + - **patch** — data-table / VirtualGrid: selecting a cell's text no longer opens the record or starts an inline edit, and Ctrl/Cmd+C copies the selected text instead of the whole cell value. A p… (objectui `fbb4b23bf`) + - **patch** — The Approvals Inbox opens a request on the page the platform serves for it, and its side sheet is gone (objectui#12484). (objectui `a91f6bbb1`) + - **patch** — The package dialog's Dependencies row is now an author-facing editor (objectui#13278). It used to be the spec form's raw JSON box under the developer hint "widget object-fields —… (objectui `53dea27af`) + - **patch** — The app's report index (`/apps/APP/reports`) and the ⌘K palette's report list now include the reports a module contributes to the app's navigation (`navigationContributions`), not… (objectui `9e1bff63a`) + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ chore: release packages (#11600) (objectui `3aa2f5261`) + + Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) + + objectui range: `cf153eb2e535...56cf4d8e89b7` +- 9a48ae8: Console (objectui) refreshed to `5fef032b1ada`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 1 releasing of 2 changesets added across 2 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). + + - **minor** — A navigation entry's API `dataSource` and a page element bound to the same read now make one request per console open (objectui#13323). Before, `readElementApi` shared a request o… (objectui `b911ebb4e`) + + Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) + + objectui range: `0d12e95361b2...5fef032b1ada` +- 9057fd4: Console (objectui) refreshed to `cd14d8eeffe1`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 1 releasing of 1 changeset added across 1 non-merge commit. + + - **patch** — The bulk action confirm step reads `confirmText` as a string or as a `{ dialect: 'template', source }` envelope (objectui#13183). (objectui `cd14d8eef`) + + Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes) + + objectui range: `56cf4d8e89b7...cd14d8eeffe1` + ## 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..55ac9441cda 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,200 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data change this relies on is the operator's ownership ceremony, `os migrate organization-ownership`, which an upgraded database must have passed before it boots. --> + + **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. +- b964ea1: feat(spec,core,objectql): one package entry of a release artifact may state its own `source`, and the registry records it + + Clause-②: yes + Metadata-protocol: added + + `ArtifactPackageSchema` (one `packages[]` entry of an assembled release artifact) gains an optional `source: 'code' | 'marketplace'` beside `manifest`. The values are the new `ArtifactPackageSourceSchema`, the subset of `InstalledPackageSourceSchema` a load path can register; `authored` and any other value are refused at the entry gate as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). + + The `manifest` service registers each package with the `source` its entry states, so a merged artifact that carries marketplace installs beside the deployment's own code lists them as `marketplace` on `GET /api/v1/packages`. An entry that states nothing takes the registering door's statement as before (`register(artifact, { source })`, `code` when absent), so existing artifacts register unchanged. An entry's own statement wins over the door's. + + `@objectstack/core` adds `resolveArtifactPackageEntries(artifact)`, which returns each package to register as `{ manifest, source? }` in dependency order. `resolveArtifactPackageOrder` is unchanged and still returns the bodies alone. + +### 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. +- 8dfd126: fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The narrowing is one more organization write the last-administrator guard refuses at runtime. --> + + **BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. + + - **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. + - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. + - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. + - **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. + + **What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. +- d196748: fix(core)!: `resolveLocalizationContext`'s direct `sys_setting` fallback reads the caller's organization's rows only, never another organization's row or a row with no organization outside a lean embedding + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) Which stored `sys_setting` row the localization fallback takes changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, error code or route is removed or renamed: `resolveLocalizationContext` keeps its signature. The remedy for the rows this stops reading is the existing organization-ownership ceremony, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** read narrowing (ADR-0131 D8 "no layer has a NULL arm"). It changes which stored row a localization read takes when the settings service cannot answer, not what a metadata author can write, so it ships as `patch` with this banner. + + - FROM: when the settings service was absent or threw (a namespace not yet bound, a failed organization or posture read), `resolveLocalizationContext` read `sys_setting` tenant rows for `timezone` / `locale` / `currency` with no organization filter and took the first row per key. A signed-in user of one organization could be answered with another organization's timezone, locale or currency, or with a row written with no organization, and the success cache could hold that answer. + - TO: the fallback reads with the reach the settings service reads with: + - A caller that names an organization reads that organization's rows, under every posture. + - A caller that names none reads the one organization ADR-0131 D9 derives under `single`. Behind a wall (`group` / `isolated`, the requested posture), at zero or several organizations, or when the organization read fails, it reads nothing and the built-ins `UTC` / `en-US` answer. + - A composition that registers no `sys_organization` (a lean embedding) reads the rows written with no organization, the only rows it can hold. + + **Who is affected.** Only a deployment whose settings service fails a localization read, and only for an organization with no tenant localization row of its own, or a store holding organization-less tenant localization rows. A healthy settings service never reaches this read. + + **The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each localization value again from inside the organization it belongs to (Setup → Localization, or `PUT /api/settings/localization` with that organization active). +- db1c7cc: feat(core): `isCloudManaged()` is the one reader of `OS_CLOUD_MANAGED`, and a production boot that sets it without a bound control plane refuses to start + + Clause-②: yes (widening) + Metadata-protocol: none + + - **New exports of `@objectstack/core`:** `isCloudManaged()`, `resolveControlPlaneApiKey(configured?)` and `resetCloudManagedForTesting()`. `isCloudManaged()` is synchronous and answers `true` when `OS_CLOUD_MANAGED` is exactly `1`, `false` when it is unset or empty; any other value is refused with an error that names it. The answer is memoized for the process; tests that set the variable call `resetCloudManagedForTesting()` before and after. + - **The fuse.** On a production boot (not `--dev`, which sets `NODE_ENV=development` only when `NODE_ENV` is unset, and not `NODE_ENV=test`; so `NODE_ENV=production os serve --dev` counts as a production boot and is refused the same way), `OS_CLOUD_MANAGED=1` with `OS_CLOUD_API_KEY` unset or empty refuses: `ObjectKernel.bootstrap()` and `LiteKernel.bootstrap()` reject before any plugin initialises, and `isCloudManaged()` throws the same error. The cloud sets both variables on the runtimes it hosts. A self-hosted deployment that has `OS_CLOUD_MANAGED=1` in a copied env file now fails to boot; remove the variable, which a self-hosted deployment never needs. + - **One caveat:** the fuse reads the environment only. A runtime that binds its control plane solely through `CloudConnectionPlugin`'s `controlPlaneApiKey` option, and also sets `OS_CLOUD_MANAGED=1` in production, is refused. Set `OS_CLOUD_API_KEY` instead. + - **`@objectstack/cloud-connection`:** the plugin resolves its control-plane credential through `resolveControlPlaneApiKey(controlPlaneApiKey)`, so the plugin's "platform-managed" answer and the fuse share one derivation. Its behaviour is unchanged: the option wins, then `OS_CLOUD_API_KEY`, trimmed. +- b3034ee: refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security + + Clause-②: yes (widening) + Metadata-protocol: none + + Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before. + + - **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing: + - behind a wall, no grant does; + - under `single`, the grant owned by the Default Organization does; + - an organization-less grant never does, and neither does any grant when no Default Organization can be named. + + Three readers call it: + - the authorization resolver passes the memoised Default Organization; + - the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it; + - the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion. + + Each reader keeps its own normalizer for a grant row's organization. + - **The organization-reach rule (ADR-0131 D8/D9).** New exports: + - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`; + - `organizationReachAdmits(reach, rowOrganizationId)`; + - `organizationReachRowOwner(reach)`; + - `organizationIdOf(value)`; + - `UNOWNED_ORGANIZATION_REACH`; + - the types `OrganizationReach` and `OrganizationReachFacts`. + + The rule decides whose `sys_setting` rows a caller may draw on: + - the caller's own organization; + - for a caller naming none where no wall stands, the organization D9 derives; + - nothing behind a wall; + - rows with no organization, only in a composition that registers no organization object. + + `SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts: + - the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller; + - the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults. + + These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader. + + No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action. +- 27b3f1f: feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both + + Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`) + Metadata-protocol: none + + Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it. + + - **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read. + - **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force. + - **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing. + - **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`. + - **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values. + - **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..a6f737d303a 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,36 @@ # create-objectstack +## 18.1.0 + +### Patch Changes + +- 80ec71d: fix(cli): retire the scaffold's five stale `peerDependencyRules` entries on the better-auth 1.7.12 pin + + Clause-②: no + Metadata-protocol: none + + A project scaffolded by `objectstack init` or `npx create-objectstack` no longer carries a `peerDependencyRules.allowedVersions` block in its `pnpm-workspace.yaml`. All five entries suppressed peer mismatches that better-auth 1.7.12 no longer has: `better-auth` declares no `better-sqlite3` peer, and `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/scim` and `@better-auth/sso` each peer `@better-auth/utils` as `^0.4.2 || ^0.5.0`, which the resolved 0.5.0 satisfies. `allowedVersions` only silences a report, so a scaffold resolves the same lockfile as before and its first `pnpm install` stays free of unmet-peer warnings. Projects scaffolded earlier can keep or delete the block; either way nothing changes. +- b6e8707: fix(cli,create-objectstack): new projects declare vitest `~4.1.12`, outside GHSA-qxg6-89mg-rqv8, and still install on npm 10 + + Clause-②: no + Metadata-protocol: none + + The vitest range every scaffold declares moves from `~4.0.0` to `~4.1.12`: the `create-objectstack` blank template, `os init -t plugin` and `os create plugin` (in `devDependencies`), and the npm `overrides` block that every standalone scaffold emits, `os init -t app` and `-t empty` included. `~4.0.0` resolved to vitest and `@vitest/mocker` 4.0.18, inside GHSA-qxg6-89mg-rqv8 (`@vitest/mocker` from 2.1.0-beta.7 up to the fix in 4.1.12), which has no 4.0.x fix. A new project now resolves vitest and `@vitest/mocker` 4.1.12 or a later 4.1.x patch, and `npm audit` of a fresh scaffold lists no vitest or `@vitest/*` advisory. + + npm 10, the npm Node 22 bundles, still installs every scaffold. The `overrides.vitest` entry, which equals `devDependencies.vitest`, is what makes that hold: it settles vitest 4.1.12's contradictory optional peer (`@vitest/browser-webdriverio`), which npm 10 otherwise crashes on with `Cannot read properties of null (reading 'edgesOut')`. Measured on npm 10.9.4, 10.9.9 and 11.21.1. pnpm ignores npm's `overrides` field. + + **An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`** keeps its own `~4.0.0`. To move it, set `overrides.vitest` to `~4.1.12` in `package.json`, and `devDependencies.vitest` too where the project declares it, then run `npm install`. npm refuses an override that differs from the direct dependency (`EOVERRIDE`), so the two move together. +- d0c56a2: fix(cli,create-objectstack): new projects override `uuid` below 12.0.0 to `^11.1.1`, so `npm audit` lists no GHSA-w5hq-g745-h8pq entry + + Clause-②: no + Metadata-protocol: none + + `@objectstack/rest` depends on `exceljs ^4.4.0`, and exceljs 4.4.0, its newest release, depends on `uuid ^8.3.0`. GHSA-w5hq-g745-h8pq affects `uuid` below 11.1.1, so `npm audit` in a fresh npm-installed scaffold reported 9 moderate entries, all from that one advisory. The npm `overrides` block every standalone scaffold emits now carries `"uuid@<12.0.0": "^11.1.1"`: the `create-objectstack` blank template, `os init` (`-t app`, `-t plugin`, `-t empty`) and `os create plugin`. A new project resolves `uuid` 11.1.1 and `npm audit` lists no `uuid` entry, measured on npm 10.9.9 and 11.21.1. exceljs calls only `uuid`'s `v4()`, which `uuid` 11 keeps; exceljs's buffered and streaming writers, its loader and `@objectstack/rest`'s `parseXlsxToRows` were run on the installed tree. pnpm ignores npm's `overrides` field. + + The selector stops at `<12.0.0`, so a project can still add a newer `uuid` of its own: `npm install uuid@14` installs. A spec-less `npm install uuid` is refused with `EOVERRIDE` (npm reads it as `uuid@*`, which meets the selector); name the major instead. + + **An existing project made by an earlier `create-objectstack`, `os init` or `os create plugin`**, or any other npm project that installs `@objectstack/rest`, directly or through `@objectstack/runtime`, `@objectstack/cli`, `@objectstack/plugin-auth` or `@objectstack/verify`: add `"uuid@<12.0.0": "^11.1.1"` to the `overrides` block of its root `package.json` and run `npm install`. npm reads `overrides` only from the root manifest, so no `@objectstack/*` release can apply it for you. + ## 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..a882384acec 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,86 @@ # @objectstack/driver-memory +## 18.1.0 + +### Minor Changes + +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..87f9e63e6cc 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/driver-mongodb +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..74322d9df3b 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,265 @@ # @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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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__'`). +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. +- 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the SQL driver's PostgreSQL introspection gives for a connection whose search_path reaches more than one schema: a table in a later schema now carries its own columns, and a table name reached in two schemas is refused rather than answered with one of the two tables. The remedy is connection configuration (one schema per connection) or a rename on the remote database, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 `<file>.corrupt-<timestamp>`, 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 '<name>': connect failed — <reason>`. `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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes it is raised with grows by one), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` gives for a SQLite file that is not a database: a rejection where it resolved. The remedy is the datasource's file, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 `<file>.corrupt-<timestamp>` 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `resolveSqliteAbsentFileTarget` gives under `sqliteAbsentFile: 'empty-in-memory'` for a SQLite path that cannot exist: it throws the driver's `SqliteDatabaseFileError` where it answered `:memory:`. The remedy is the datasource's `filename`, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + `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 '<name>': 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 `<file>.corrupt-<timestamp>` 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes and paths it is raised for grows), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` and the read-only judgement give for three SQLite files a datasource cannot use: a rejection where each resolved. The remedy is the datasource's file or its path, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 `<file>.corrupt-<timestamp>` 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 + + <!-- adr-0087: not-required (no-migration-prescription) A storage constraint at CREATE TABLE: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is removed or renamed; `TENANT_SCOPE_FIELD_DEF` keeps its shape and only its description text is corrected. The remedy is a run-time one, stated below. --> + + **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: <table>.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. +- 5d66021: fix(driver-sql): on a database the organization-ownership ceremony has not certified, the boot's drift line for an orphaned `organization_id` column names the ceremony instead of `os migrate apply --allow-destructive` + + Clause-②: no (operator log line text only) + Metadata-protocol: none + + A 17.x database booted on 18.x printed one `[schema-drift]` line per `organization_id` column ending `— "os migrate apply --allow-destructive" to drop it.`, during `os serve` and `os migrate apply --yes`. That command is refused on such a database: the drop is the ceremony's (ADR-0131 D10 item 5). The boot now asks the driver's own reader, `isTenantKeyCertified()`, once per table that has such a drop. When the answer is "not certified", the line keeps the finding and replaces that sentence with `— a drop the organization-ownership ceremony owns, and it has not verified on this database: run "os migrate organization-ownership", which drops it itself.` These are the words `os migrate plan` shows under the same entry. + + A certified database prints the same line as before. So does every other column and every other drift kind. Nothing the boot applies or skips changes. +- 8c73398: fix(driver-sql,cli): `os migrate plan` / `apply` no longer offer `--allow-destructive` for a unique index the existing rows already violate + + Clause-②: yes (widening) + Metadata-protocol: none + + The duplicate pre-flight that blocks such a `create_index` (ADR-0120 D4) said so only in the entry's message, and the apply-limit seam the two commands read asked the driver by op type. So `os migrate plan` closed with `(add --allow-destructive for drops / tightenings)`, `os migrate apply --yes` said `re-run with --allow-destructive to include them`, and that re-run refused the entry and ended PARTIAL. + + - **`ManagedDriftEntry.applyLimit` (`@objectstack/driver-sql`, new and optional).** Why the reporting driver's apply door will not perform this entry on this database, read from its data. The duplicate pre-flight sets it on a blocked unique index, naming the conflicting groups. It is absent on every other entry, so a reader that ignores it sees the report as before. On `--json`, a blocked entry now carries this key. + - **`os migrate plan` / `os migrate apply` (`@objectstack/cli`).** The apply-limit seam reads the entry's own limit first, then the driver's per-op one. A blocked unique index is listed with the conflicting groups as the reason it cannot be applied, the footer and the skip lines stop offering `--allow-destructive` for it, and it is counted among the changes that remain drift. A database with no blocked index prints as before. + + Unchanged: `os migrate apply --allow-destructive` still names such an entry as not applied and reports the run PARTIAL with exit 1, because the database still differs from its metadata. To clear it, deduplicate the listed rows and run `os migrate plan` again. +- c8f4c43: fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number + + Clause-②: yes (widening) + Metadata-protocol: none + + A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. + + - **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. + - **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. + - **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. + - **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. + - **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. +- 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. +- 3a440b0: refactor(objectql)!: the per-table platform-object tenancy ledger is retired, and the tenant-audit mute is decided by the object's schema (ADR-0131 D13) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#PLATFORM_OBJECT_TENANCY, @objectstack/objectql#classifyPlatformObjectTenancy, @objectstack/objectql#isPlatformObjectOutOfTenantAuditScope, @objectstack/objectql#tenantScopedPlatformObjects, @objectstack/objectql#PlatformObjectTenancy, @objectstack/objectql#PlatformObjectTenancyEntry) The only author-reachable removals are runtime exports of `@objectstack/objectql`'s published entry: the ledger table, its three predicates and its two types. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. --> + + **BREAKING** for code that imports the ledger from `@objectstack/objectql`. It ships as `patch` because the metadata protocol is unchanged. + + - **`@objectstack/objectql`: the ledger and its exports are gone.** No write ever depended on them: since ADR-0131 C8 stage 8b, every object with a tenant column takes the system-write rule's derived organization or is refused, whatever its name. The ledger's last reader was the engine's tenant-audit mute, which is now read off the schema. + - **The tenant-audit mute (`bypassTenantAudit` on an elevated write) is decided by the object's schema alone.** An elevated write is muted only when the object has no tenant column (`tenancy.enabled: false`, `systemFields.tenant: false`, or no tenant field) or is federated. A platform-namespace object that has a tenant column is now audited like any other. On a walled posture (`isolated` / `group`), an elevated write on such an object that reaches the SQL driver with no `tenantId` therefore prints the `[tenant-audit]` warning, once per object and operation. Nothing a write touches changes: the mute only decides whether that line is printed. + - **`@objectstack/driver-sql`: the `[tenant-audit]` warning names both readings.** It no longer tells every unscoped write to "pass tenantId". A write that belongs to one organization should run in that organization's context. A write that crosses organizations on purpose, such as a platform admin the organization wall exempts or a system job over every organization, needs no fix, and the caller declares it with `bypassTenantAudit: true`. The line still starts `[tenant-audit] <op> on tenant-scoped object "<object>"`, and its metadata is unchanged. + + **Migration** for code that imported the ledger: + + - FROM `PLATFORM_OBJECT_TENANCY` TO the object's schema: an object is in tenant scope when `resolveTenantFieldName(schema)` (still exported) answers non-null. `scripts/platform-object-tenancy-census.json` in the repository lists, per platform object, the schema declaration that puts it outside. + - FROM `classifyPlatformObjectTenancy(name)` TO `resolveTenantFieldName(schema) !== null` on the registered schema. + - FROM `isPlatformObjectOutOfTenantAuditScope(name)` TO `resolveTenantFieldName(schema) === null`. + - FROM `tenantScopedPlatformObjects()` TO the census's `reach: "in"` rows. + - FROM `PlatformObjectTenancy` TO delete it: no classification exists any more. + - FROM `PlatformObjectTenancyEntry` TO delete it: no classification exists any more. +- b62f959: fix(driver-sql): the `[tenant-audit]` warning no longer fires for an insert whose rows carry their organization + + Clause-②: no + Metadata-protocol: none + + A `create` or `bulkCreate` that reaches the SQL driver with no `options.tenantId` used to print `[tenant-audit] … without options.tenantId` even when every row already named its organization in its tenant column (`organization_id`). Such an insert is confined to the organizations its rows name, so the line had nothing to report. A walled boot (`OS_TENANCY_POSTURE=isolated`) printed one for every elevated seed insert: 32 of its 55 lines. + + The line is now skipped when every inserted row carries a non-empty tenant column. It still fires for an insert where any row lacks the column, and for every `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` and `upsert` with no `tenantId`, whatever the payload says: there the column is a value being written, not the statement's scope. The line's text and prefix are unchanged. + + Diagnostics only: no write changes. Turso's local face and the SQLite WASM driver inherit the change; the in-memory and MongoDB drivers have no such warning. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..ed2f74c6271 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,96 @@ # @objectstack/driver-sqlite-wasm +## 18.1.0 + +### Patch Changes + +- 27dc53b: fix(driver-sqlite-wasm)!: a file wasm SQLite cannot read is refused at connect, naming the file, and left as it was found, instead of being renamed and replaced with an empty database (#25019) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`WasmSqliteConnection` and `SqliteWasmDriver` keep their shapes; the private quarantine method is gone), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer wasm SQLite's connect gives for a database file it cannot read: a rejection naming the file where it renamed the file and resolved over an empty database. The remedy is the datasource's file, never a rewrite of a package source or a stored row. --> + + **BREAKING** (wasm SQLite's connect accept set narrows). `WasmSqliteConnection.open()` used to answer a file sql.js could not read by renaming it to `<file>.corrupt-<timestamp>` and starting an empty database in its place, with one warning. That self-heal was written for wasm SQLite's own torn writes, which the atomic flush has since closed; what still reached it was an operator's file on a declared datasource. A datasource declared `driver: 'sqlite-wasm'` over such a file read `available` over the empty replacement, in development and production alike, and a declared `sqlite` datasource on a development boot whose native addon cannot load stepped down to wasm SQLite and met the same rename. The native driver already refuses the same files at connect. + + - **FROM:** a file sql.js cannot read (plain text, another format, a database truncated inside its header or short of the pages its schema names) was moved to `<file>.corrupt-<timestamp>`; `connect()` resolved over an empty database, and the datasource read `available`. On a development boot whose native addon cannot load, `resolveSqliteDriver` (`@objectstack/service-datasource`) returned that wasm driver, and when wasm SQLite failed for any reason it went on to the in-memory driver. + - **TO:** `SqliteWasmDriver.connect()` rejects with the native driver's `SqliteDatabaseFileError`: `code: 'SQLITE_NOTADB'` for bytes that are not a database, `code: 'SQLITE_CORRUPT'` for a database it cannot read, `filename`, and a message naming the file and how to move it aside. Nothing is renamed, nothing is written beside it, and a stale write-ahead log beside a refused file stays where it is. `WasmSqliteConnection.open()` rejects with the same code and `filename`. `resolveSqliteDriver` re-throws that refusal at the wasm rung instead of trying the in-memory driver, so the datasource's connect fails and its connection state says so. Any other wasm failure, sql.js itself not loading first among them, still goes on to the in-memory driver. + - **Unchanged:** a real database opens and answers; an absent file is created; an empty file opens as an empty database; a native addon that cannot load over a real or absent file still steps down to wasm SQLite. + - **The fix for an operator:** restore the database from a backup, or move the unreadable file aside yourself (`mv '<file>' '<file>.unreadable'`) and start again: an absent file is created empty on the next connect. A development server whose own wasm image is damaged no longer starts itself clean; this is that step, done by hand. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- Updated dependencies [5d66021] +- 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 [8c73398] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..cae029db2c6 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,224 @@ # @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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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__'`). +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. +- 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. + + <!-- adr-0087: registered turso-config-local-encryption-key-refused --> +- 2354058: feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired --> + + `encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed. + + **BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). + + - **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them. + - **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words. + - **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server | + | `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | + | `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest | + + The fix in one line: delete `encryptionKey` from every turso datasource. + + ### The ADR-0087 disposition + + - **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value. + - A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand. + - The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it. + - `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`. + + ### Stored rows stay redacted + + A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike. + + ### Blast radius + + Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it 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 + + <!-- adr-0087: not-required (no-migration-prescription) A storage constraint at CREATE TABLE: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is removed or renamed; `TENANT_SCOPE_FIELD_DEF` keeps its shape and only its description text is corrected. The remedy is a run-time one, stated below. --> + + **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: <table>.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 }`. +- 0b8bce4: fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before. + - **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face. + - **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500. + - **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves. +- c8f4c43: fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number + + Clause-②: yes (widening) + Metadata-protocol: none + + A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. + + - **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. + - **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. + - **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. + - **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. + - **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- Updated dependencies [5d66021] +- 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 [8c73398] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..1c61ca9301c 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,97 @@ # @objectstack/formula +## 18.1.0 + +### Minor Changes + +- 9d96b2b: fix(formula): `has()` over a selection whose receiver is an index or a call result evaluates as a presence test, as the CEL spec defines it + + Clause-②: yes (widening) + Metadata-protocol: added + + The CEL spec defines `has(e.f)` for any expression `e`. The engine used to refuse every such argument whose selection chain was not rooted in a plain name — `has(data.environments[0].storage.usedMb)`, `has(record.items[0].name)`, `has(dyn(x).y)` — with `has() invalid argument`, at evaluation and at `compile` / `validateExpression` alike. A page or view `visibleWhen`, whose publish gate checks syntax but not types, published such a predicate and then failed open in the console. + + - **What now works.** The call is a presence test on the last field: `true` when the receiver has the key, `false` when it does not, and a fault (`No such key`) when a step before the last one is absent, exactly like `has(record.a.b)`. `evaluate`, `compile`, `validateExpression`, `firstUndeclaredReference` and `inferCelType` all give that one answer. + - **What widens.** Object, flow, action, sharing-rule and hook predicates that `validateExpression` used to refuse for this argument now pass. They evaluate as written. + - **What is still refused.** An argument the spec itself refuses: a bare name (`has(status)`) or an index as the last step (`has(record.items[0])`). Test a key with `'key' in m`, or a list position with `size(list) > n`. + - **Nothing to migrate.** A predicate already written in the `'key' in e` form keeps working and answers the same. + +### Patch Changes + +- 9127f23: fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189) + + Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the verdict the authoring doors give an email template whose HTML body places a hole outside the supported placements. A stored template keeps rendering and is judged at its next publish, and the remedy is the author moving the hole, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). --> + + **BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105). + + The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements. + + - **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`. + - **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well. + - **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200. + - **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added. + - **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged. + - **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape. + - **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's. + - **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes. + - **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..4cbe5a10440 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,411 @@ # @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) + + <!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. --> + + **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 `<ListView filterableFields={[…]}>` 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; + - `<ListView filterableFields>` 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` | + | `<ListView filterableFields={['status']} />` 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: '<target>' }` (`Field.lookup('<target>', …)`), 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.<node_id>.label`, the step label of an `approval` node + + Clause-②: yes (widening) + + - **Before.** `TranslationDataSchema.flows.<flow>` carried `label`, `successMessage`, `errorMessage`, `screens.<node_id>` (screen nodes) and `refusals.<node_id>` (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.<node_id>.title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen. + - **Now.** `flows.<flow>.approvals.<node_id>.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.<node_id>.title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals.<node_id>.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. +- 2069b81: feat(spec)!: a `user` field whose `reference` names an object other than `sys_user` is refused at parse, and every `user` field resolves to `sys_user` + + Clause-②: no (narrowing) + Metadata-protocol: narrowed + + <!-- adr-0087: registered user-field-foreign-reference-refused --> + + **BREAKING** (the accept set of `FieldSchema` narrows), shipped as `minor` under the repo's launch-window convention for breaking changes. + + - **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, and `referenceTargetOf` resolved it to `probe_account`. Readers that follow the target (`$expand`, imports, seed loading, field paths) treated the field as a link to `probe_account`, while readers that branch on the type (the person picker, a `current_user` default, a sharing rule over the users a field holds) treated its values as users. + - **FROM:** a `user` field with `reference` naming any object but `sys_user` parsed, and resolved to the named object. + - **TO:** `FieldSchema` refuses it with an issue at `reference` that names the replacement, so `ObjectSchema`, `ObjectExtensionSchema`, `defineStack`, `os validate` and the metadata save door refuse it too. `referenceTargetOf` answers `sys_user` for every `user` field, whatever `reference` says, so a stored body saved before this release resolves to `sys_user` as well. `Field.user()`'s config types `reference` as `'sys_user'` and writes `sys_user` after the config spread. + - **Unchanged:** a `user` field without `reference`, with `reference: 'sys_user'` or with a blank `reference`, and `Field.user()`'s output for every typed call. + - **Fix:** change such a field to a `lookup` on the object it names, `{ type: 'lookup', reference: 'TARGET' }` (`Field.lookup('TARGET', …)`), and choose its `deleteBehavior` on purpose: a `lookup` defaults to `'set_null'` when the target record is deleted, which a `user` field never did. A `current_user` default is not legal on a `lookup` to an object other than `sys_user`. If a person was meant, drop `reference`. + - **Why no automatic rewrite.** No ADR-0087 conversion can carry the change losslessly: a `lookup` joins the referential delete walk and `deleteBehavior` has no value that means "leave it". `objectstack migrate meta` reports the semantic entry `user-field-foreign-reference-refused` instead. + + **Supersedes the `user-field-foreign-reference` warning entry in this release.** That entry announced a `@objectstack/lint` advisory `user-field-foreign-reference`, the `USER_FIELD_FOREIGN_REFERENCE` export and its `os explain` entry, and said such a field would still parse. All three are withdrawn before release and never ship: a foreign `reference` on a `user` field is now refused by `FieldSchema` instead, so there is nothing left for the advisory to warn about. + + - **For `@objectstack/lint`:** no `user-field-foreign-reference` rule, no `USER_FIELD_FOREIGN_REFERENCE` export and no `RULE_EXPLANATIONS` entry for it. `OBJECT_REFERENCE_UNKNOWN` and every other export are unchanged. The refusal arrives as a `FieldSchema` issue at `reference`, not as a lint advisory. +- 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. +- c5915b2: fix(lint)!: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent` (#24957) + + Clause-②: no (narrowing: `os build` and the object save door refuse a `validations[]` rule whose predicate reads `parent`, which the server's rule evaluator never binds) + Metadata-protocol: narrowed + + A `validations[]` rule's predicates are 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`. The server evaluates them 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 reads `parent`, such as `parent.status == 'paid'`, faults (`Unknown variable: parent`) on every write it judges, and the server refuses that write as unevaluable, the seed's own rows at boot included. + + The build's expression rule (`validateStackExpressions`) reported such a predicate at `warning` (#24720). This entry supersedes that entry's sentence "Nothing new is refused in 18.x" and its plan to refuse in 19.0: the refusal ships now, in this release, and this entry is the current statement. The rule now refuses such a predicate at `error`, at the same location (`object 'NAME' · validation 'RULE'`, `… when`, or the nested rule's label), with the same rule id (`expression-invalid`). The message no longer says it is a warning. The object save door runs the same pass, so its verdict is the build's finding. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` fail on a `validations[]` rule whose predicate reads `parent`. The rule is judged as written, so this also covers a rule marked `active: false`, a rule whose `events` a stack never fires, and a `conditional` branch whose `when` is never true. + - An object write in publish mode that carries such a rule answered 200, with the finding among its advisories. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **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. To refuse a whole write on the header's state (for example a new line under a paid invoice), use a lifecycle hook (`beforeInsert` / `beforeUpdate` / `beforeDelete`) that reads the header. For any other check, read a column the detail object declares, and denormalise the header value you need onto the detail. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The runtime is unchanged: a `validations[]` rule still binds `record` and `previous` and no `parent`, and such a rule already refused every write it judged. If the runtime comes to bind `parent` there, this refusal is withdrawn in that same change. + - `parent` on a field's own `readonlyWhen` / `requiredWhen` is still accepted with no finding, and so is a `record` column spelled like the root (`record.parent_code`). + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - No example, template or package object in this repository authors `parent` in a `validations[]` rule. + - No public export or signature moves. + + <!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build` and at the object save door of a validations[] rule predicate that reads `parent`, which the server's rule evaluator never binds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object carrying such a rule keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (a field rule, a lifecycle hook, or a denormalised column), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). --> +- 30d21f6: feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label` + + Clause-②: yes (widening) + + - **Before.** `pages.<page>.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.<page>.components.<tabsId>.items.<value>.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.<object>._views.<view>.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.<view>` 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.<object>._views.<view>.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.<key>` 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.<view>.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. + + <!-- adr-0087: not-required (no-migration-prescription) a refusal at the CLI doors and the page save door of a page component whose properties bag is present but not an object: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page with such a component keeps loading until it is next saved, and the repair is the author restoring the props the component lost, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). --> +- 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. + + <!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build`, `os validate`, `os lint` and the page save door of a page component props CEL slot whose source overruns the platform's parse bounds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page whose slot is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (shrinking it, or precomputing it into a formula or roll-up field), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). --> +- f5b6b3f: fix(lint)!: a page or view `visibleWhen` that hands `has()` an argument other than a field selection is refused at publish + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered visibility-predicate-has-argument-refused --> + + **BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes. + + CEL defines `has()` in one form, `has(e.f)`, with `f` a field name after a dot. The engine refuses any other argument as `has() invalid argument` on every evaluation, whatever the record holds. Every other predicate surface already refused it through `validateExpression`. The page and view `visibleWhen` door did not, so `has(status)` and `has(record.items[0])` published clean, and the console, which renders a faulting predicate as visible, drew the guarded element for every user. + + - **FROM:** a `visibleWhen` on a page component, a form view section or a section field could hand `has()` a name alone (`has(status)`), an index as the last step (`has(record.items[0])`), or any other argument that is not a field selection (`has(record.?a)`, `has(upper(record.x))`, a literal, an operator), and `os validate`, `os build`, `os lint` and the page and view save doors accepted it. + - **TO:** each is refused as `visibility-predicate-has-argument`, an error, at those doors (the save doors answer `422 INVALID_METADATA`). The finding quotes the argument and the engine's wording, and its hint names the spelling to write. `os explain visibility-predicate-has-argument` says why. + - **Unchanged:** `has(e.f)` for any `e`, including a selection over an indexed or called receiver (`has(record.items[0].name)`); every other predicate, and nothing about the types that flow through one is judged. A `has(status) && status == 'x'` keeps its `visibility-bare-identifier` finding on the unguarded `status`, beside the new one. A stored page or view row in the refused shape still loads and is served; its next save is refused, and `os migrate meta --stored` lists each such predicate under `visibility-predicate-has-argument-refused`. + - **The fix for an author:** write `has(record.status)` or `'status' in record` for a field (`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether a list has a first element (`'k' in e` for a bracketed string key). + +### 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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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) + + <!-- adr-0087: registered add-record-mode-form-view-removed, add-record-enabled-defaulted, add-record-mode-form-view-retired, add-record-enabled-required --> + + **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`. +- 459afea: fix(lint): the record-title diagnostics no longer steer a composite title that must be queried into a formula field + + Clause-②: no (wording only: no verdict, severity, code, key or export moves) + Metadata-protocol: none + + The `title-format-retired` and `title-unresolvable` messages and fix lines, the `object/missing-name-field` fix line, and the `os explain title-format-retired` text now say the same thing: a composite record title that an import, a sort or a search must match is a stored computed field, a non-formula field (such as a `text` field) that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`. A `formula` field with `returnType: 'text'` stays a legal `nameField` for a title shown for display only: its value is computed on read and stored nowhere, so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell against it. + + No finding fires on a different object than before, and no severity changes. Only the text an author reads changes. +- 02ad31a: feat(spec)!: a `controlled_by_parent` object whose one required `master_detail` is marked `readonly: true` or `system: true` is refused at parse + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered cbp-master-relation-tiers-retired) registered before this change, amended in this diff: the parse's one-master entry now carries this narrowing, and the stored-row listing reports it under that id --> + + **BREAKING** (a declaration the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage. + + ADR-0055 §1: a `controlled_by_parent` object derives its record access from exactly one required `master_detail` field. That one master must be a reference every write of a detail supplies, and a field declared `readonly: true` or `system: true` is not one an author's write supplies, so it cannot be that master. `os lint` already reported it at `error` under `relationship/master-detail-required`, but `os lint` is not a publishing door: `os validate`, `os build`, `ObjectSchema.create()`, `defineStack` and the metadata save door accepted it. + + - **FROM:** `sharingModel: 'controlled_by_parent'` with one required `master_detail` declared `readonly: true` and/or `system: true` parses, builds and saves. + - **TO:** the parse refuses it at `fields`, naming the object, the field and each flag (the message opens with "Object 'OBJECT' declares sharingModel 'controlled_by_parent' with its one required master_detail, FIELD, marked readonly: true", with each name and flag in a code span). The save door answers `422 INVALID_METADATA` with that issue and stores nothing. `ObjectSchema.create()` refuses it too, because the parse it ends in is the same rule. An objectExtensions entry that leaves the merged master flagged is refused at the extension, as before for the other shapes. + - **The fix:** remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`). + - **Unchanged:** the runtime. The flags do not change which field the runtime resolves as the master. A flag on a second, non-required `master_detail` beside the master is still reported by `os lint` alone. + + Data at rest still loads. A stored object in this shape registers under its `[metadata_spec_invalid]` diagnostic, and `os migrate meta --stored` lists it as a TODO under `cbp-master-relation-tiers-retired`. No conversion rewrites it: dropping the flag changes who may write the reference, which is the owner's decision. + + `@objectstack/spec/data`: `controlledByParentMasterRefusal` returns the new shape `master-not-writable` for this object, so a producer that asks it before writing `controlled_by_parent` gets the parse's verdict. `ControlledByParentMasterShape` gains that member. `isControlledByParentMasterField` is unchanged: a flagged required `master_detail` still counts as the master, so beside a second required one the object is still refused as having two. + + `@objectstack/lint`: the `relationship/master-detail-required` explanation (`os explain`) now says that the parse refuses every shape that leaves the object without exactly one writable required master, and that a shape on a second, non-required `master_detail` is this rule's report alone. +- 9127f23: fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189) + + Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the verdict the authoring doors give an email template whose HTML body places a hole outside the supported placements. A stored template keeps rendering and is judged at its next publish, and the remedy is the author moving the hole, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). --> + + **BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105). + + The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements. + + - **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`. + - **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well. + - **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200. + - **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added. + - **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged. + - **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape. + - **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's. + - **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes. + - **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..9c5a43b92cc 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,106 @@ # @objectstack/plugin-mcp-server +## 18.1.0 + +### Patch Changes + +- 8010b64: fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition the stdio MCP transport consults: its object discovery and its API-exposure gate now read the protocol's merged view, the definition REST and the `/mcp` HTTP bridge already serve and enforce, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the stdio data verbs' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests the stdio transport serves, not what a metadata author can write. + + The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `list_objects` and `describe_object` from the `metadata` service, and gated its data verbs and its record resource on the same service's copy of the object. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_objects` answered `{ objects: [], totalCount: 0 }` for such a package, `describe_object` answered "not found" for each of its objects, and the gate read the missing copy as "no declaration" and served them. REST and the `/mcp` HTTP bridge listed and refused the same objects. + + - **Before.** On that kernel, an object a package registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record`, `aggregate_records` and the `objectstack://objects/NAME/records/ID` resource, and `create_record`, `update_record` and `delete_record` reached the store. Its `internal: true` fields were echoed in the `create_record` and `update_record` responses. + - **Now.** `list_objects` and `describe_object` answer from the listing `GET /api/v1/meta/object` serves. Every data verb and the record resource refuse such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` and the HTTP bridge do. Its `internal: true` fields are omitted from the write responses, and its nested-relation conditions are gated on their targets. + - **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described; they are now omitted, as REST omits them. Every data verb and the record resource now refuse a disabled package's object `404 OBJECT_PACKAGE_DISABLED`, whatever the object declares: before, the stdio transport served it on every verb, because each verb asks the engine and never the protocol. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing. + - **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before. A host with no `protocol` service, or one without the merged reads, keeps reading the `metadata` service. If the protocol's by-name read fails, the gate judges the `metadata` service's copy, as it did before. + - **What to do.** Nothing, if the declarations say what you mean. A stdio client that relied on reaching an object declared `apiEnabled: false` was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. A stdio client that read a disabled package's objects was relying on the same kind of defect: re-enable the package to restore access, as REST says. +- 72fad92: fix(mcp)!: the `objectstack://objects` resources read the stdio transport's merged object view, so they list and describe every object `list_objects` and `describe_object` answer, and omit a disabled package's objects as those tools do + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition the stdio MCP transport's two object resources read: the protocol's merged listing, the one its `list_objects` / `describe_object` tools and `GET /api/v1/meta/object` already serve, instead of the metadata service's copy. No published signature moves: `MCPServerRuntime.bridgeResources` keeps its two parameters, and the view reaches it through a package-internal function `index.ts` does not publish. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the object resources stop serving a disabled package's objects), shipped as `patch`: it changes which reads the stdio transport answers, not what a metadata author can write. + + The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `resources/read` of `objectstack://objects` and `objectstack://objects/NAME` from the `metadata` service. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `objectstack://objects` answered `{ objects: [], totalCount: 0 }` for such a package and `objectstack://objects/NAME` answered `404 RESOURCE_NOT_FOUND` for each of its objects, while `list_objects` and `describe_object` listed and described them. + + - **Before.** On that kernel, an object a package registered during the boot was missing from `objectstack://objects`, and `objectstack://objects/NAME` answered `{ error: 'Object "NAME" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }` for it. + - **Now.** Both resources read the view `list_objects` and `describe_object` answer from: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. `objectstack://objects/NAME` answers the same body `describe_object` answers for that name. + - **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed by `objectstack://objects` and described by `objectstack://objects/NAME`. They are now omitted, and `objectstack://objects/NAME` answers `404 RESOURCE_NOT_FOUND` for them, as `describe_object` answers "not found". When the protocol's merged listing throws, both resource reads fail with that error, as `list_objects` does, instead of answering the `metadata` service's listing. + - **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and described as before. The completeness verdict is still the `metadata` service's: a loader outage still withholds `totalCount` and answers `partial: true`, and a miss during an outage still answers `503 SERVICE_UNAVAILABLE`. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. A host that calls `MCPServerRuntime.bridgeResources` itself reads the `metadata` service as before. + - **What to do.** Nothing. A stdio client that read a disabled package's object through these resources was reading a stale copy the rest of the platform no longer serves: re-enable the package to restore it, as REST says. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..1c094d044bf 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,138 @@ # @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 + + <!-- adr-0087: registered field-select-shorthand-keeps-written-value --> + + **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) + + <!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. --> + + **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 `<ListView filterableFields={[…]}>` 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; + - `<ListView filterableFields>` 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` | + | `<ListView filterableFields={['status']} />` 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. +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..173d7cf57a3 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,14 @@ # @objectstack/metadata-fs +## 18.1.0 + +### Patch Changes + +- Updated dependencies [b42c621] +- Updated dependencies [386f60f] +- Updated dependencies [ee63b47] + - @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..8d340fa0155 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,263 @@ # @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. + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. --> + + **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`. +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. +- bda53bb: fix(metadata-protocol)!: the metadata save doors and `composeStacks` refuse an object's `external` binding on a named managed datasource, from either side of the pair + + Clause-②: yes (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused) registered by #25168 before this change, amended in this diff: its surface, replacement and reason now name composeStacks and the two metadata save doors --> + + **BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes. + + ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'` (an absent `schemaMode` is `'managed'` by the spec default). #25168 enforced the halves a parse and a single stack can see. A binding on a datasource stored or registered at runtime, or declared by another input of a composition, still got no verdict: the save door stored an `external` object on a managed datasource, stored a datasource re-saved as managed under objects bound to it with `external`, and `composeStacks` accepted the pair split across two inputs. The runtime then disagrees with itself about the object: its schema sync skips the table as federated while the boot validation and the write gate judge it as managed. + + - **FROM:** `PUT /api/v1/meta/object/NAME` stored an object with `external` and a `datasource` naming a stored or registered datasource declared `schemaMode: 'managed'` or with no `schemaMode`, in draft and publish mode and under `force`. `PUT /api/v1/meta/datasource/NAME` stored a datasource as managed (or with no `schemaMode`) while stored or registered objects bound it with `external`. `composeStacks` accepted an `external` object of one input bound to a managed datasource another input declares. + - **TO:** + - The object save door refuses the object with `422 INVALID_METADATA`, the finding at `external`, nothing stored. The datasource it judges against is the one the door's own by-name read serves: the environment's stored row, else the definition the code layer registers. + - The datasource save door refuses the datasource with `422 INVALID_METADATA`, one finding per bound object at `schemaMode`, the stored row unchanged. The objects it judges are the environment's active object rows and the registered objects no stored row overlays. An object's draft binds nothing until it is published. + - Both are judged in draft and publish mode, and again when a draft is published (`publishMetaItem` and each item of a package publish), against the other half as it stands then. `force` does not skip them. + - `composeStacks` refuses the cross-input pair with `STACK_CROSS_REFERENCE_INVALID` (422), in either input order and under every `manifest` strategy, `preserve` included. An input built with `strict: false` that holds the pair itself is refused here too, as with the artifact pass's other rules. + - Every finding is the spec predicate's, so it reads as the parse's and `defineStack`'s do: "Object 'OBJECT' declares `external` on datasource 'NAME', whose `schemaMode` is 'managed' …". + - **Unchanged:** the rule itself. A binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted on every door. A datasource no layer holds gets no verdict: the absence of a definition is no evidence about its `schemaMode`. The datasource-admin service's update (`PATCH /api/v1/datasources/NAME`) does not pass through the metadata save door and is not judged by this change. Rows already stored in the refused shape still load as before; a save of either side that leaves the pair inconsistent is refused, so the next object save waits until the datasource is external or the object drops `external`. + - **The fix for an author:** keep the pair consistent, in this order. To federate, save the datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings first, then bind objects to it. To make a datasource managed, remove `external` from the objects that bind it first, then save the datasource. + + This narrows the **Unchanged** bullet of the #25168 changeset, which said a binding on a datasource "registered by the host or another package, or stored at runtime" gets no verdict at the load doors: the metadata save doors now judge it, and `composeStacks` judges one another input declares. + + `@objectstack/spec/data`: exports `externalBindingDatasourceRefusal` and its result type `ExternalBindingDatasourceRefusal`, the one predicate behind the rule, so a runtime door resolves the datasource it can see and asks it rather than copying the judgement. Its behaviour is unchanged. +- 69913f8: fix(metadata-protocol)!: `rollbackMetaItem` and `revertCommit` refuse a stored version the save door refuses, instead of writing it back live + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused, page-default-record-page-one-per-object-refused, api-endpoint-anonymous-elevated-flow-refused) no rule is added or changed: the restore doors now ask the save door's existing refusals, and the ledger entries of the three that carry one already describe the refused shape and its fix --> + + **BREAKING** (what a metadata restore writes narrows), shipped as `minor` under the 18.x convention for breaking changes. + + The two restore doors write a stored history body back as the active row: `POST /api/v1/meta/TYPE/NAME/rollback` (`rollbackMetaItem`) and the restore limb of a commit revert (`revertCommit`, which `rollbackToPackageCommit` also uses). They asked only the save door's `name` and permission-set owner judges, and none of the refusals `saveMetaItem` runs after its schema gate. So a version saved before a refusal existed, or one half of a pair whose other half moved after the version was saved, came back live. + + - **FROM:** a rollback or a commit revert restored a version that the save door would refuse today: + - a hook with no `body`; + - a record page marked `isDefault` for an object whose default another page now holds; + - a datasource restored to `schemaMode: 'managed'` (or no `schemaMode`) while objects bind it `external`; + - an object restored with `external` on a datasource that is managed now; + - a `runAs: 'system'` flow that an anonymous (`authRequired: false`) endpoint names; + - a body the shared runtime authoring rules refuse; + - a body a domain plugin's authoring gate refuses (plugin-security's object posture gate, for example). + - **TO:** each restore door asks those refusals through the save door's own functions, with the save door's code and status, and stores nothing on a refusal. The active row, the history and the registry stay as they were. + - `rollbackMetaItem` answers the refusal: `400 VALIDATION_ERROR` for the hook, `422 DEFAULT_RECORD_PAGE_CONFLICT` for the page, `422 INVALID_METADATA` for the datasource pair, the flow and the authoring rules, and the plugin gate's own code. + - `revertCommit` reports the refused item in `failed[]` with that code and reverts the commit's other items, as it does for its other per-item refusals. + - **Unchanged:** a version the save door accepts restores as before. The restore does not run the schema parse on the history body, does not apply the read-only-base create refusal (that refusal judges a package a caller names for a new item; a restore names none and writes into the active row's own package binding), and keeps the write-only credential channel's current credential. The runtime authoring rules' advisories are not added to the restore response. + - **The fix for an author:** a refused restore names the same problem the save door names. Restore a different version, or first change the other half of the pair (for example, remove `external` from the bound objects before restoring the datasource as managed), then restore again. + +### 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) + + <!-- adr-0087: not-required (already-registered view-list-filterable-fields-removed, list-view-filterable-fields-retired) The key's retirement, its D2 conversion and its D3 entry are already registered by the change that tombstoned it, which shipped in 18.0.0. This change adds no key, conversion or stored shape: it makes `os lint` and the react-page props check give the verdict `os validate` and `os build` already give the list-view key, with the tombstone's own prescription. --> + + **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 `<ListView filterableFields={[…]}>` 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; + - `<ListView filterableFields>` 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` | + | `<ListView filterableFields={['status']} />` 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. +- e282f32: fix(service-messaging,metadata-protocol)!: a notification preference's User is a `sys_user` reference, so the record title names the person and the write door refuses an id no user holds; the default for every user is an empty User instead of `'*'` + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or exported declaration is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is a platform object this package ships and owns, `sys_notification_preference`: its `user_id` column becomes a `sys_user` lookup, and what narrows is the write accept set of that object's rows, which are admin-authored data rather than metadata. No ADR-0087 conversion reaches a stored data row; the stored rows that spelled the retired `'*'` are rewritten by this package itself at boot, and the one-default-per-key index is a runtime migration in `@objectstack/metadata-protocol`. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (a platform object's write accept set narrows, and one stored value is rewritten). It changes what a data writer can store in `sys_notification_preference`, not what a metadata author can write, so it ships as `patch` with this banner. + + - **FROM:** `sys_notification_preference.user_id` was a required text column holding a user id or the literal `'*'`, which meant "the default for every user". Any string was stored: a mistyped id matched nobody, silently. The record title (Setup → Notification Preferences) read the raw id, for example `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap · urgent_task · inbox`. Two defaults for every user could be stored for one topic and channel in one organization, and which one applied was unspecified. + - **TO:** `user_id` is an optional `sys_user` lookup (ADR-0118 D1: no sentinel in a user column). An empty User is the default for every user, and a user's own row still overrides it. + - A write that names an id no user holds, `'*'` included, is refused: the REST data door answers `400` with `code: 'VALIDATION_FAILED'` and a finding on `user_id` with `code: 'reference_not_found'`. + - The title names the person: their name, or their email where the name is blank, then the topic and the channel. A row with an empty User reads `All users · TOPIC · CHANNEL`. A row whose User this reader cannot read, or that no longer exists, has no title rather than a misleading one. + - Deleting a user deletes that user's preference rows (`deleteBehavior: 'cascade'`). Under the lookup default (`set_null`) the person's own mute would have become the default for every user. + - At every boot (`kernel:ready`), the messaging service rewrites each stored `user_id = '*'` to an empty User, in every organization. A second run changes nothing. A `'*'` row whose topic and channel already have a default with an empty User is left as stored and reported once at `error`, naming the row; it is never deleted for you. + - On SQLite and PostgreSQL, `@objectstack/metadata-protocol` rebuilds the index `uniq_sys_notification_preference_a22d7d27` at `kernel:ready` as `(organization_id, COALESCE(user_id, ''), topic, channel)`, so a second default for one topic and channel in one organization is refused (`409`). If existing rows already break that rule, it refuses to migrate: the previous index stays, no row is touched, and the boot log and `os migrate duplicates` list the rows. MySQL/MariaDB and driver-memory keep the declared index, which does not constrain empty Users, and the boot log says so on MySQL. + - **Unchanged:** `topic = '*'` and `channel = '*'` are still the topic and channel wildcards. Most-specific-wins resolution, mandatory topics, quiet hours and digests behave as before. A row for a real user is read, matched and titled as before, except for the title text. The physical column is still a `varchar(255)`, so no DDL change is needed for it. + - **The fix for an operator:** nothing, normally; the boot rewrites the old `'*'` rows. If the boot log reports a `'*'` row that shares its topic and channel with a default whose User is already empty, delete the one that should not apply and restart. If it reports that the one-default-per-key index could not be built because two such defaults exist, list them with `os migrate duplicates`, delete one, and restart. A script or import that wrote `user_id: '*'` writes an empty `user_id` (omit it, or send `null`) for the default for every user. +- 17f3c65: fix(metadata-protocol)!: the Data API refuses a malformed or negative page window with a 400 naming the parameter as written, instead of a 500 or a silently ignored window + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the list-query normalizer gives a request whose page window is not a non-negative integer. The remedy is in the request (send a count, or leave the parameter out), never a rewrite of a package source or a stored row. --> + + **BREAKING** accept-set narrowing on the list doors (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`, and the runtime dispatcher's data list). It changes what a Data API caller can send, not what a metadata author can write, so it ships as `patch` with this banner. + + - **Before.** The list-query normalizer coerced the window with a bare `Number(...)`. A negative offset (`?$skip=-1`, `{"offset": -5}`) reached the SQL driver, which threw, and the caller got `500 INTERNAL_ERROR`. A limit that was not a number or was negative (`?$top=abc`, `?$top=-1`, `{"limit": -1}`), and an offset that was not a number (`?$skip=abc`), were dropped, so the caller got every row under a `200`. A blank `?$top=` became `0` and answered an empty page. A fractional window (`{"offset": 1.5}`, `{"limit": 1.5}`) was rounded by the driver. + - **Now.** `limit` / `offset` and their spellings `$top`, `top`, `$skip`, `skip` must be non-negative integers. Anything else is refused before any read with `400 INVALID_REQUEST`, and the message names the parameter the caller wrote: `Query parameter '$skip' must be a non-negative integer; got "-1".` A blank value is refused too (`?$top=`, `?$skip=`), not read as absent: an absent window means every row, so reading a blank one that way would be the silent widening this closes. + - **Unchanged.** A well-formed window pages exactly as before (`?$top=5&$skip=2`, `{"limit": 5, "offset": 2}`), and `0` stays valid on both (`$top=0` asks for the total alone). A non-number in a `POST` body (`{"limit": "ten"}`) is still the body schema's `400 VALIDATION_FAILED`. + + **The remedy.** Send the window as a whole count of zero or more, or leave the parameter out. +- 5f30f00: fix(spec)!: the published list-query schemas declare the page window the Data API enforces — a non-negative integer — instead of any number + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no metadata type, authorable metadata key, object definition or stored shape is added, removed, renamed or re-shaped. What narrows is two request schemas a Data API caller is validated against (`FindDataRequestSchema`, through `QueryWithTransportSchema` and `QueryTransportParamsSchema`, and `HttpFindQueryParamsSchema`), and every value they now refuse was already refused by the list doors since the page-window normalizer landed. There is nothing for `objectstack migrate meta` to rewrite; the remedy is in the request (send a whole count, or leave the parameter out). --> + + **BREAKING** accept-set narrowing on two published request schemas. It changes what a schema-validated Data API request may carry, not what a metadata author can write, so it ships as `patch` with this banner. + + - **Before.** `FindDataRequestSchema` (the `POST /api/v1/data/:object/query` body) declared `limit` / `offset` / `top` as any number and `$top` / `$skip` / `skip` as any number or string, and `HttpFindQueryParamsSchema` (the `GET /api/v1/data/:object` querystring) declared `top` / `skip` as `z.coerce.number()`. A client generated from them, or a request validated against them, was told `-1`, `1.5` or a blank `?top=` was valid, and the door then refused it with a `400`. + - **Now.** Each of those parameters declares the page window the door enforces: a non-negative integer, or on the string-carrying spellings the digits a querystring carries it as (`'50'`). `-1`, `1.5`, `NaN`, `Infinity`, a blank or whitespace-only string, and a string that is not a count fail the parse. The bound is exported once, as `QueryPageWindowSchema` / `QueryPageWindowParamSchema` (`@objectstack/spec/data`), and the list-query normalizer in `@objectstack/metadata-protocol` now judges every window by that same schema, so the two accept sets are one declaration. + - **One answer moves, and only its code.** On `POST /api/v1/data/:object/query` (and the runtime dispatcher's `POST` twin) a malformed window is now refused by the body schema, like every other malformed body: `400 VALIDATION_FAILED` with `fields[0].field` naming `query.limit` / `query.offset`, where it was `400 INVALID_REQUEST` from the normalizer. Still a `400`, still before any read. `GET /api/v1/data/:object` has no body schema and answers `400 INVALID_REQUEST` naming the parameter as before. + - **Unchanged.** A well-formed window parses and pages exactly as before, `0` included (`$top=0` asks for the total alone). Windows inside `expand` and the engine and driver contracts (`QuerySchema`, `EngineQueryOptionsSchema`) are not narrowed: no door refuses those. + + **The remedy.** Send the window as a whole count of zero or more, or leave the parameter out. +- 7db1697: fix(spec): `os migrate meta --stored` lists a stored object bound `external` to a stored or registered managed datasource + + Clause-②: yes (widening) + + Metadata-protocol: none + + The stored-shape listing for `object-external-binding-managed-datasource-refused` resolved an object's named datasource only from the stack being converted. A stored object row reaches the conversion chain alone, so the listing judged only a binding on the primary datasource (`'default'`), while the object save door also refuses one on a datasource stored or registered beside the object. The stored-metadata pass therefore did not list a stored object row bound `external` to a managed datasource, the row whose next save is refused with `422 INVALID_METADATA`. + + - **Now:** for each stored `object` row carrying `external` and naming a datasource, the pass reads that datasource the way the object save door does (the environment's stored row, else the definition the code layer registers; both go through one read) and hands it to the chain. The row is listed under `rows[].todos` with this entry's id exactly when that door refuses its next save. A datasource declared `schemaMode: 'external'` or `'validate-only'`, and a datasource no layer holds, give no listing, as at the door. Nothing is rewritten. + - **`@objectstack/spec`:** `ApplyConversionsOptions` (and so `StoredConversionOptions` for `applyConversionsToStoredItem`) gains an optional `knownDatasources`, a map of datasource definitions by name that the caller's door can see beyond the stack. Only the refused-shape listing reads it, so only when an `onTodo` sink is passed. A datasource the stack declares is read from the stack first. Without it, behaviour is unchanged. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- Updated dependencies [0a3ce08] +- Updated dependencies [c5915b2] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [459afea] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/lint@18.1.0 + - @objectstack/metadata-core@18.1.0 + - @objectstack/formula@18.1.0 + - @objectstack/metadata@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..9a11861a835 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,76 @@ # @objectstack/metadata +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..9bc890d5da3 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,432 @@ # @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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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__'`). +- d5d8146: feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + + Clause-②: yes (widening) + Metadata-protocol: semantic + + 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + + - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). + - **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(<tenant>, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. + - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. + - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. +- 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<DroppedFieldsEvent['reason'], …>` 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the organization delete gives while the organization still owns records (409 instead of a 500 that left a partial delete, or a 200 that orphaned the rows to NULL), and the engine's answer to a `sys_organization` delete that rows still name in their tenant column. The remedy is an operator action on the organization's records, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed, one engine method is added (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 <path>`.** 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`) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored shape or exported declaration is added, removed, renamed or narrowed, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the engine gives an in-process caller that opts into `strictReadonlyWrites` and sends a value a stored expression then replaces: that write is refused where it completed and was reported. The remedy is the caller's own payload (leave the field out, or send the computed value), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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). +- b964ea1: feat(spec,core,objectql): one package entry of a release artifact may state its own `source`, and the registry records it + + Clause-②: yes + Metadata-protocol: added + + `ArtifactPackageSchema` (one `packages[]` entry of an assembled release artifact) gains an optional `source: 'code' | 'marketplace'` beside `manifest`. The values are the new `ArtifactPackageSourceSchema`, the subset of `InstalledPackageSourceSchema` a load path can register; `authored` and any other value are refused at the entry gate as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). + + The `manifest` service registers each package with the `source` its entry states, so a merged artifact that carries marketplace installs beside the deployment's own code lists them as `marketplace` on `GET /api/v1/packages`. An entry that states nothing takes the registering door's statement as before (`register(artifact, { source })`, `code` when absent), so existing artifacts register unchanged. An entry's own statement wins over the door's. + + `@objectstack/core` adds `resolveArtifactPackageEntries(artifact)`, which returns each package to register as `{ manifest, source? }` in dependency order. `resolveArtifactPackageOrder` is unchanged and still returns the bodies alone. + +### 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. +- 9340181: fix(objectql): the write preview (`validate()`, the import dry run) answers a row's `organization_id` from the Layer 0 tenant wall's own verdict + + Clause-②: yes (widening) + + Metadata-protocol: none + + `validate()` runs no middleware, so the tenant write wall never judged a preview row. On a walled deployment (`group` or `isolated`) the preview reported a caller's `organization_id` as dropped where the write stores it as sent, and answered valid a row naming an organization the write refuses with 403. + + - **New, optional: `ObjectQL.registerWriteWallVerdict(fn)`**, beside `registerWriteGateProbe`, with its types `WriteWallVerdict` and `WriteWallRowVerdict`. The layer that owns the wall registers it (`@objectstack/plugin-security` does), and the engine never decides the verdict itself. + - **`validate()` reads each row's verdict.** A row whose organization the wall judges keeps `organization_id` through the static `readonly` strip, so no `droppedFields` entry names it, as on the write. A row the wall refuses answers `valid: false` with the write's own code (`PERMISSION_DENIED`) and message, under `organization_id` for a refused value and `_record` for the caller's state, and it drops nothing. Rows stay independent, and the set's `valid` stays the conjunction. A row the strips changed is asked about again, as the write's stored-image half judges it. + - **Unregistered, nothing changes**: the strip runs as before, which is what the write does in a composition without the security plugin. + - **Named limit:** the preview runs no hooks, so an organization a `beforeInsert` / `beforeUpdate` hook writes is judged by the write alone. +- c8f4c43: fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number + + Clause-②: yes (widening) + Metadata-protocol: none + + A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. + + - **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. + - **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. + - **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. + - **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. + - **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. +- 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. +- 504d8b3: fix(objectql)!: a `controlled_by_parent` detail whose master the engine cannot compose a read over now refuses the boot, instead of booting and failing at read time + + Clause-②: yes (narrowing) + + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) A boot refusal of one deployment composition: no key of any metadata schema is added, removed, renamed or re-shaped, and `ObjectSchema.parse` accepts exactly what it accepted before, so `objectstack migrate meta` has nothing to rewrite and no tombstone is owed. Both facts the refusal reads are deployment facts (datasource mapping, a package's default datasource, lifecycle routing, the middleware registrations), and the remedy is the author's choice among three, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this composition (not already-registered); and nothing exported is removed or narrowed, one engine method is added (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (a narrowing of what boots). A stack declaring a `controlled_by_parent` detail over either class of master below booted before this change. It is now refused at `kernel:bootstrapped`, before `kernel:listening` opens the socket. It ships as `patch` because no metadata protocol shape moves. + + ADR-0139 D5.2 and D5.3 record two classes of master the engine composes no `ReadableIds` leaf over, and refuses by name at read time: + + - **(a)** a master bound to another datasource than the detail. No driver evaluates a subquery across two datasources. + - **(b)** a master whose rows an object-scoped read middleware narrows (`registerMiddleware(fn, { object })`), for example `sys_attachment`, `sys_comment`, `sys_activity`, `sys_audit_log`, `sys_mail_message` or `sys_approval_request`. That WHERE is built at read time and is no part of the master's read scope, so a leaf over it would be wider than a read of the master. + + A `controlled_by_parent` detail derives its access from its one required master (ADR-0055 §1). A detail over such a master is a declaration the runtime cannot honour, so under ADR-0049 it is no longer admitted. `ObjectQLPlugin` refuses the boot with `INVALID_METADATA` / 422, naming each detail, its master field, the master and the class, and the remedies below. + + - The check is judged at `kernel:bootstrapped`, not at `kernel:ready` or `registerApp`. Several platform plugins (mailbox, audit, attachment storage) register their object-scoped read middleware from their own `kernel:ready` handler, which runs after the engine's, so only then are both facts final. + - It reuses the read path's own predicate (`hasObjectMiddleware` and the datasource binding resolution), so the boot rule and the read rule cannot drift. The read-time refusal is unchanged. + - New engine method: `ObjectQL#findUnservableControlledByParentMasters()` lists the offending details. + + No producer is known to be affected. HotCRM's six `controlled_by_parent` objects all sit on one datasource over masters with no object-scoped read middleware, and no in-repo declaration has either shape. + + ### Remedy + + Pick one, for each detail the refusal names: + + - bind the detail and its master to one datasource (the same `datasource`, mapping rule or package default); + - or choose a master that no object-scoped read middleware narrows; + - or declare the detail with a sharing model other than `controlled_by_parent`, and grant its access directly. +- 3a440b0: refactor(objectql)!: the per-table platform-object tenancy ledger is retired, and the tenant-audit mute is decided by the object's schema (ADR-0131 D13) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#PLATFORM_OBJECT_TENANCY, @objectstack/objectql#classifyPlatformObjectTenancy, @objectstack/objectql#isPlatformObjectOutOfTenantAuditScope, @objectstack/objectql#tenantScopedPlatformObjects, @objectstack/objectql#PlatformObjectTenancy, @objectstack/objectql#PlatformObjectTenancyEntry) The only author-reachable removals are runtime exports of `@objectstack/objectql`'s published entry: the ledger table, its three predicates and its two types. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. --> + + **BREAKING** for code that imports the ledger from `@objectstack/objectql`. It ships as `patch` because the metadata protocol is unchanged. + + - **`@objectstack/objectql`: the ledger and its exports are gone.** No write ever depended on them: since ADR-0131 C8 stage 8b, every object with a tenant column takes the system-write rule's derived organization or is refused, whatever its name. The ledger's last reader was the engine's tenant-audit mute, which is now read off the schema. + - **The tenant-audit mute (`bypassTenantAudit` on an elevated write) is decided by the object's schema alone.** An elevated write is muted only when the object has no tenant column (`tenancy.enabled: false`, `systemFields.tenant: false`, or no tenant field) or is federated. A platform-namespace object that has a tenant column is now audited like any other. On a walled posture (`isolated` / `group`), an elevated write on such an object that reaches the SQL driver with no `tenantId` therefore prints the `[tenant-audit]` warning, once per object and operation. Nothing a write touches changes: the mute only decides whether that line is printed. + - **`@objectstack/driver-sql`: the `[tenant-audit]` warning names both readings.** It no longer tells every unscoped write to "pass tenantId". A write that belongs to one organization should run in that organization's context. A write that crosses organizations on purpose, such as a platform admin the organization wall exempts or a system job over every organization, needs no fix, and the caller declares it with `bypassTenantAudit: true`. The line still starts `[tenant-audit] <op> on tenant-scoped object "<object>"`, and its metadata is unchanged. + + **Migration** for code that imported the ledger: + + - FROM `PLATFORM_OBJECT_TENANCY` TO the object's schema: an object is in tenant scope when `resolveTenantFieldName(schema)` (still exported) answers non-null. `scripts/platform-object-tenancy-census.json` in the repository lists, per platform object, the schema declaration that puts it outside. + - FROM `classifyPlatformObjectTenancy(name)` TO `resolveTenantFieldName(schema) !== null` on the registered schema. + - FROM `isPlatformObjectOutOfTenantAuditScope(name)` TO `resolveTenantFieldName(schema) === null`. + - FROM `tenantScopedPlatformObjects()` TO the census's `reach: "in"` rows. + - FROM `PlatformObjectTenancy` TO delete it: no classification exists any more. + - FROM `PlatformObjectTenancyEntry` TO delete it: no classification exists any more. +- b62f959: fix(objectql): the engine declares the organization wall's exemption to the driver, so an exempt platform admin's predicate write is not told to declare an option it cannot set + + Clause-②: no + Metadata-protocol: none + + Under a walled posture an exempt platform admin's predicate update or delete reaches the driver with no `tenantId`, because the wall lifted the scope on purpose (ADR-0131 D8). The SQL driver's `[tenant-audit]` line then told the caller to declare `bypassTenantAudit: true`, an option no REST caller can set. + + The engine now fills in `bypassTenantAudit: true` on the driver options when the Layer 0 verdict the security layer recorded for the operation is `none` and the same decision without the exemption would have walled it. It does not fill it in when no verdict was recorded (a by-id write, a system context, no security layer), under the `single` posture, for an insert, for an object outside the tenant machinery, or when the caller supplied its own value. + + Diagnostics only: the driver's scope was already the recorded verdict's, and the option only silences the warning. +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- Updated dependencies [5e9b100] +- Updated dependencies [7532ea3] +- Updated dependencies [fde7456] +- Updated dependencies [b42c621] +- Updated dependencies [de9cd1f] +- Updated dependencies [e7befa7] +- Updated dependencies [55d7aab] +- Updated dependencies [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [17f3c65] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [69913f8] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/metadata-protocol@18.1.0 + - @objectstack/metadata-core@18.1.0 + - @objectstack/formula@18.1.0 + - @objectstack/metadata@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..30aa4a9c419 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,63 @@ # @objectstack/observability +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..a9cba671983 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,326 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) One shipped object field gains `relatedList: false`, a key it was always free to set. No spec key, authorable spelling, export, type or stored shape is added, removed or renamed, and no stored row or column is read or rewritten, so `objectstack migrate meta` has nothing to rewrite. What narrows is one derived related list on a team's served detail page. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this field (not already-registered); and the change is metadata on a shipped object, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) One shipped object field, `sys_business_unit.parent_business_unit_id`, gains `deleteBehavior: 'restrict'`, a key every lookup was always free to set, and its help text says what that means. No spec key, authorable spelling, export, type or stored shape is added, removed or renamed, and no stored row or column is read or rewritten, so `objectstack migrate meta` has nothing to rewrite. What narrows is the accept set of one delete: a business unit that still has child units is refused where it was accepted. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this field (not already-registered or registered); and the change is metadata on a shipped object, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. The one definition that changes is the option list of one field on `sys_oauth_application`, a platform object this package ships and owns, and what narrows is the set of values a write to that object's rows may send. Those rows are OAuth client registrations the OAuth provider writes, not metadata, and no ADR-0087 conversion reaches a stored data row. No writer sends either retired value, no stored row is rewritten, and a row holding any value reads and takes every write that leaves `type` out. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer one runtime REST door gives a caller whose request body names an application type the OAuth provider has no counterpart for; the remedy is a different value in that request, never a rewrite of a package source or a stored row. The Console form that posts to this door already offers only Web and Native, and `sys_oauth_application` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: registered sys-notification-subscription-retired --> + + **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 + + <!-- adr-0087: registered field-select-shorthand-keeps-written-value --> + + **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. +- dfab597: feat(spec): `ISharingRuleService` declares the optional `declaredRuleNamed` reader that the catalog activation door uses for declared sharing rules + + Clause-②: yes (widening) + Metadata-protocol: none + + - **The contract member.** `ISharingRuleService` (`@objectstack/spec/contracts`) gains an optional `declaredRuleNamed?(name)` member. It returns `Promise<{ name: string; packageId?: string } | null>`: the rule the application declares under that name, or `null` when nothing servable is declared under it. The answer covers every organization, and it does not depend on whether the activation ledger has the rule switched off. `POST /api/v1/security/_activation/sharing_rule/:name` reads it to confirm the name it is asked to switch. Before this change the door read the method by duck type, and the contract did not mention it. + - **Optional, so no implementer breaks.** An implementation that serves no declared rules can leave it out. A caller probes for it and refuses fail-closed when it is absent. The activation door still answers `503 SERVICE_UNAVAILABLE` in that case, so the door's behaviour does not change. `@objectstack/plugin-sharing`'s `SharingRuleService` already implements it with a compatible signature. + - **The ledger's field description.** On `sys_metadata_activation`, the `metadata_type` description now lists every type the ledger holds: `flow`, `action`, `position`, `permission`, `sharing_rule` and `webhook`. It used to stop at `flow`, `permission`, …. +- c109bfc: fix: the `sys_user` page and list views show no 2FA state on a deployment where two-factor authentication is off + + Clause-②: no + Metadata-protocol: none + + `plugin-auth` resolves `twoFactor` to `false` unless a deployment turns it on, and `/api/v1/auth/config` then serves `features.twoFactor: false`. The platform's own `sys_user` declarations showed a 2FA state anyway, with no way to act on it: a "Two-Factor Enabled: No" highlights chip, a 2FA column in the `all_users` and `me` list views, a "2FA Enabled" view tab that could only ever be empty, and a Security-tab "Two-Factor Authentication" heading above an empty action bar. + + - **Record page, Security tab:** the 2FA heading, its caption, the 2FA actions and the divider that closes the block carry one component `visibleWhen`, `has(features.twoFactor) && features.twoFactor == true`. With 2FA off, or before the auth config has loaded, the block is not drawn. With 2FA on, the block also shows the user's 2FA state (`two_factor_enabled`) beside the enable, disable and backup-code actions. + - **Record page, highlights:** `two_factor_enabled` is no longer a highlight chip. A highlight field has no per-field gate, so the state moved into the gated Security block. The details grid still hides the field. + - **List views:** `all_users` and `me` drop the `two_factor_enabled` column, and the `two_factor` ("2FA Enabled") list view is retired. A list column or view has no feature gate, and adding one would be a new authorable key. + + The `two_factor_enabled` field itself is unchanged. A deployment with 2FA on that wants the column back can add it to its own list view. +- f4112a7: feat(platform-objects): the console hides the six doors that add people while `features.cloudManaged` is on + + Clause-②: no + Metadata-protocol: none + + A cloud-managed environment's people arrive from the organisation's identity provider, so the console no longer offers the ways to add them by hand there. While `GET /api/v1/auth/config` serves `features.cloudManaged: true`, these actions are hidden: + + - `invite_user` on `sys_user`, `sys_member`, `sys_invitation` and `sys_organization`; + - `sys_user.create_user`; + - `sys_user.set_user_password`. This one is hidden in the console only: the server keeps answering it for a local (`env_native`) account, so the break-glass admin stays editable. + + Each action keeps the gate it already had (`organization` or `admin`), and the new gate is added beside it, so nothing changes where the flag is off. +- 830a63a: feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System" + + Clause-②: no + Metadata-protocol: none + + A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor: + + - **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes. + - **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before. + - **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System". +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- bdf762c: fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/platform-objects`:** `sys_business_unit.kind` declared its options as bare strings, so each label equalled its stored value and the form offered `cost_center`. The options now carry labels (Company, Division, Department, Office, Cost Center). The stored values are unchanged. + - **`@objectstack/plugin-sharing`:** `sys_sharing_rule.recipient_type` and `access_level` declared bare-string options too. They now carry labels (User, Team, Business unit, Position, Business unit and subordinates, Field; Read, Read and edit), which are the names the Recipient Type help text already used. `sys_record_share` declares the same recipient and access vocabulary, so its `recipient_type` (User, Group, Position, Business unit and subordinates) and `access_level` options carry the same labels. The stored values are unchanged. + - **`@objectstack/spec`:** the `autoMigrate` key of the SQLite, PostgreSQL and MySQL driver configs had no JSON-Schema `title`, so the New datasource form labelled the field with the raw key `autoMigrate`. It is now titled "Auto-migrate schema". + + The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept. +- 3306cad: feat: Create User can grant a permission set in the same step, judged by the same delegated-administration gate as Setup's grant form + + Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` and the `sys_user` `create_user` action accept an optional `permissionSet`; a request without it is answered exactly as before) + Metadata-protocol: none + + A user created with no permission set signed in to a screen of refusals. Setup → Users → Create User now offers an optional **Permission Set** (the set's machine name, checked against the security catalog). + + - **How the grant is written.** The route inserts the `sys_user_permission_set` row under the calling administrator's OWN execution context, never the system's. The security middleware, the ADR-0090 D12 delegated-administration gate (which stamps `granted_by`) and the catalog name check judge it exactly as they judge the grant form. The route's platform-admin gate decides who may create a user; it never decides what that user may be granted. + - **One step, one outcome.** The account and the grant run in one engine transaction. A refused grant answers the refusal's own status and code (403 `PERMISSION_DENIED` from the gate, 400 `VALIDATION_FAILED` with its `fields` for a name the catalog does not hold), and no user is created. A datasource whose driver has no `beginTransaction` runs the two writes unatomically, the engine's declared limit (ADR-0119 D1). + - **Refused before anything is written:** a `permissionSet` that is not a string (400), a caller whose own context does not resolve (403), and a host that wires no grant seam (501). + - **Response.** The success body carries `permissionSet` beside the user, and the result dialog shows it. + - **Cloud-managed environments are unchanged.** `create_user` stays hidden while `features.cloudManaged` is on. +- c898bd8: feat: Setup → API Keys → Create Key offers the key's expiry, and a date past the policy's maximum is refused with the policy named + + Clause-②: yes + Metadata-protocol: none + + Create Key asked only for a name, so every key silently got the default life the Authentication API key policy sets, and the list then showed an expiry the admin never chose. The `sys_api_key` action `create_api_key` now declares an optional `expires_at` param, the second body key `POST /api/v1/keys` already reads. Its help text says what an empty field means (the policy's Default key lifetime, 90 days unless changed) and that a date later than the policy's Maximum key lifetime is refused. The param has no literal default on purpose: an action param default is submitted verbatim, so a fixed date there would override the deployment's policy instead of following it. The reveal dialog now shows the expiry the key actually got, beside the key. + + The route behaviour is unchanged: an empty field gets the policy's default life, and a date past the maximum is refused `400 API_KEY_EXPIRY_TOO_LONG`, never clamped. That refusal's message now names the setting that decided it ("the Authentication API key policy's Maximum key lifetime is N days") next to the latest date allowed. +- 97aa34e: fix: Setup → Sessions shows each session's user agent and last activity, so two sessions of one user can be told apart + + Clause-②: no + Metadata-protocol: none + + Two sessions of one user read the same in the `sys_session` list: same user, organization and creation minute, with no device or activity column. An administrator who wanted to end one of them could not tell which, and revoked both. + + The two views that list live sessions, `all_sessions` (Setup's Sessions page) and `mine` (the Account app), now show `user_agent` and `last_activity_at` next to the IP address. Both fields already existed on `sys_session`. The `revoked` audit view is unchanged. + + `user_agent` is the raw string better-auth records. `last_activity_at` is written only while an idle timeout is configured (`session_idle_timeout_minutes` above 0). With the default of 0 the column is empty. +- f7d7403: fix(platform-objects): Setup → People & Organization no longer shows two identical "组织" / "組織" entries + + Clause-②: no + Metadata-protocol: none + + `nav_organization` (the active organization's record page) and `nav_organizations` (the organization list, shown where `org-scoping` is mounted) both translated to "组织" in zh-CN and "組織" in ja-JP, so a multi-organization deployment showed two identical menu entries that open different pages. They now read "当前组织" / "所有组织" (zh-CN) and "現在の組織" / "すべての組織" (ja-JP). A new test fails when any two entries in one Setup nav group read the same in any shipped locale. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..aee789db055 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,63 @@ # @objectstack/embedder-openai +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..b069716adf8 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/knowledge-memory +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..b206daa4091 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/knowledge-ragflow +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..ff238d8de5e 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/organizations +## 18.1.0 + +### Patch Changes + +- e3190b9: fix(organizations)!: the first organization no longer adopts organization-less rows — `claimOrphanOrgRows` is retired + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/organizations#claimOrphanOrgRows) The one author-reachable removal is a runtime export: the function `claimOrphanOrgRows` leaves `@objectstack/organizations`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. --> + + **BREAKING** for a walled store (`group` or `isolated`) that holds application rows with no `organization_id` when its first organization is created, and for code that imports `claimOrphanOrgRows`. It ships as `patch`: no metadata an author writes changes. + + - **The first organization adopts nothing.** When the per-org seed replay landed no row and the new organization was the store's first, `OrganizationsPlugin` used to write that organization's id onto every organization-less row of every application object (`claimed N orphan seed row(s) for first organization …`). Being created first names no owner, so this was an owner by guess, which ADR-0131 D9 and D10 forbid. Under a wall, the only posture that mounts this plugin, the organization-ownership ceremony reports such a row, leaves it NULL and holds it against certification; the claim then gave the same row to whichever organization existed. Those rows now stay NULL. + - **A first organization with no seed replay logs that it starts empty**, the line every later organization already logs. The organization count the claim needed is no longer read. + - **Unchanged:** the per-org seed replay, the organization stamp on insert, the Default Organization bootstrap, and `claimOrgSeedOwnership`, which hands an organization's seeded rows to its owner. + + **What an operator does.** On a store this release creates or certifies there is no such row, so nothing. On a walled store an earlier release created that holds organization-less application rows, run `os migrate organization-ownership`: it attributes every row an anchor names, and lists each row it cannot attribute with its remedy instead of guessing an owner. + + The retired export: + + - FROM `claimOrphanOrgRows` TO no replacement; run `os migrate organization-ownership` for organization-less rows. +- Updated dependencies [448374d] +- 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 [abc25a3] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [688052e] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [c6bdcac] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [829cb77] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [431327b] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..ebc4f2c3b0d 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,179 @@ # @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. + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. --> + + **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.<flow>.label` and `flows.<flow>.approvals.<node_id>.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.<flow>.label`, `flows.<flow>.approvals.<node_id>.label` and `objects.<object>.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.<object>.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. +- d12e650: feat: `ExecutionStatus` gains `missed`, the run status for a scheduled flow's due time that passed while no kernel was ticking + + Clause-②: yes (widening) + Metadata-protocol: none + + `ExecutionStatus` gains a tenth member, `missed`, appended last so every reader that indexes `.options` keeps its positions. It records a scheduled flow's due time that passed while no kernel was ticking, written once by the next kernel that finds the gap, one entry per missed due time. It is terminal: the run never started, is never resumed and is never replayed. It is distinct from `cancelled` (nobody cancelled it) and from `failed` (nothing ran). + + No field is added. On an `ExecutionLog`, `startedAt` is the due time and `completedAt` the detection time; `durationMs` and `refusalMessage` are absent and `steps` is empty. On the stored run row, `started_at` and `finished_at` carry the same two instants, and `duration_ms`, `error` and `refusal_message` are absent. + + `ListRunsRequestSchema.status` and the REST `?status=` filter read the enum, so both accept `missed`. `AutomationResult.status`, `TriggerFlowResponseSchema.data.status` and `JobExecutionStatus` do not change. + + `@objectstack/plugin-approvals`: `RUN_STATUS_LIVENESS` maps `missed` to `terminal`, so an approval pending on a missed run is swept like one pending on any other finished run. + + This release only declares the value. The engine writes `missed` rows in a later release. +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..9932fdadcb8 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,142 @@ # @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`. +- 0f1eeee: fix(plugin-audit,service-storage): a delegated (on-behalf-of) caller reads the parent-record gates through the ReadableIds leaf, no longer through the bounded 2000-row probe (ADR-0139 D5.1, as amended) + + Clause-②: no + Metadata-protocol: none + + The activity, ledger and comment-thread read gates (`@objectstack/plugin-audit`) and the attachment read gate (`@objectstack/service-storage`) routed every read by a delegated caller to the whole-read probe: a SYSTEM pre-scan of at most 2000 rows, then one caller-scoped read per parent object. Past 2000 candidate rows, a delegated caller was served a truncated list (fail-closed, never a leak) with a matching `total`. + + Since the engine composes a `ReadableIds` leaf for a delegated caller from the delegator intersection (ADR-0090 D10, #25009), the gates now write such a caller the same leaves as any caller. It is served every row its delegator intersection admits, with a true `total`. This is the same set the probe's own readability answer admits, without the window. ADR-0139 D5's two remaining classes are unchanged: a parent narrowed by an object-scoped read middleware, and a parent bound to another datasource. Both still take the bounded probe, read with the caller's delegated context. The gates no longer read the delegation link themselves, and the `delegated-caller` value is gone from the gates' internal whole-read probe reasons. Neither is exported. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [9340181] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..5a6a0150d7d 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,514 @@ # 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: <role>`) 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. + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer one runtime REST door gives a caller whose request body names an application type the OAuth provider has no counterpart for; the remedy is a different value in that request, never a rewrite of a package source or a stored row. The Console form that posts to this door already offers only Web and Native, and `sys_oauth_application` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) a narrowing of CORS admission at three doors plus a boot refusal of one more configuration class: no authorable metadata key, spelling, type or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. The remedy is a deployment variable or the door's own origin option, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is an admission verdict, not an interface, a type or an export retirement (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the organization delete gives while the organization still owns records (409 instead of a 500 that left a partial delete, or a 200 that orphaned the rows to NULL), and the engine's answer to a `sys_organization` delete that rows still name in their tenant column. The remedy is an operator action on the organization's records, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed, one engine method is added (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. +- abc25a3: fix(plugin-auth)!: the last-admin guard and the default-organization bootstrap count only the Default Organization's `admin_full_access` grant — an organization-less grant is no administrator + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The rows this reads differently exist only on a database the ownership ceremony has not certified, or in a composition that registers no organization object. --> + + **BREAKING** for a host that holds an `admin_full_access` grant row with no `organization_id` (`NULL`, absent or blank). Since the resolver stopped reading such a row as platform-admin standing (ADR-0131 D1/D5), the two plugin-auth readers that still counted it now read it the same way. + + - **`registerLastAdminGuard` (the last-administrator guard).** FROM: an organization-less `admin_full_access` grant counted as a platform administrator under `single`, beside the Default Organization's. TO: only the Default Organization's grant counts, and only under `single`; with no nameable Default Organization (a composition that registers no organization object among them) no grant counts. The configured administrator list (`OS_PLATFORM_OWNER_EMAIL`, verified accounts) and organization owners and admins count as before. + - The guard now **refuses** a write it used to permit: ending the standing of the last administrator the resolver recognises while an organization-less holder exists. Counting that holder let the deployment's only platform administrator be banned, deleted or downgraded. + - It now **permits** a write it used to refuse: banning, deleting or revoking an organization-less holder who is nobody's administrator. + - Under a walled posture, a switched-off or dangling organization-less grant is no longer "emptied environment" evidence, and the refusal no longer carries its walled remedy sentence (no grant is evidence there). + - **`ensureDefaultOrganization` (the bootstrap's grant anchor).** It reads only the Default Organization's `admin_full_access` grant, and no grant when no Default Organization is nameable. An organization-less grant holder is never bound as the Default Organization's owner. The configured owner is read first, as before. + + **Who sees it.** No served composition: a fresh install, a certified 18.0.0 database and a ceremonied database hold no organization-less grant, and the engine derives or refuses one wherever the organization object is registered. What changes is a host that embeds the guard on an engine with no organization object (for example on driver-memory), or runs plugin-auth outside `os serve` on a database the ceremony has not certified. + + **What an embedder does.** Anchor your administrators the way ADR-0131 D5 names: declare them in `OS_PLATFORM_OWNER_EMAIL` (their accounts verified), or give them an owner or admin `sys_member` row. Or register the organization object (`sys_organization`, which plugin-auth ships) and own the grant by the Default Organization. On a database 17.x or an 18.0 pre-release created, run `os migrate organization-ownership`, which attributes every organization-less grant to an organization. +- e5f194b: fix(plugin-auth): an organization delete whose admission cannot read what it decides on is refused with 503 `SERVICE_UNAVAILABLE`, before better-auth's first write + + Clause-②: no + Metadata-protocol: none + + The refusal replaces an outcome only a failed store read produces, and the typed 503 replaces a bare 500 fault; no request a caller can make is answered differently. + + The `POST /organization/delete` admission counts the records an organization still owns before better-auth's handler runs, so a refused delete writes nothing on any driver. + + - **A read the admission decides on that fails now refuses.** When its read of the caller's session, of the caller's membership or of the organization row failed, the admission handed the request to better-auth, which could then clear the caller's active organization and delete the members and invitations with nothing counted. On a driver without transactions those deletes stayed while the organization row survived. The admission now answers `503` with the standard `SERVICE_UNAVAILABLE` code, before better-auth's first write. The body names the table it could not read; the driver's own message is not relayed. A failed session read used to answer the same as no session at all; the two are now told apart, and only no session still goes to better-auth's `401`. + - **A failed Default Organization decision answers the same `503`.** It already refused before any write, but as a bare error, which better-auth answers with an untyped `500`. + - **Unchanged.** What better-auth answers itself still goes to it: no session, no membership, no permission, no organization row, and a member row whose role the vendor's own permission check cannot evaluate (its identical check runs before its first write). The `409 DELETE_RESTRICTED` refusal and the Default Organization's `403` are unchanged. + + **What a client does.** Treat `503 SERVICE_UNAVAILABLE` from an organization delete as "retry later": nothing was deleted. +- 2aaee3b: feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered + + Clause-②: yes + Metadata-protocol: none + + The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered. + + - **When the explicit refusal stays, unchanged.** + - The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered. + - The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused. + - The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account. + - **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart. + - **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32. + - **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder. + - **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step. + - **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap. + - **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail. +- c6bdcac: fix(plugin-auth)!: a cloud-managed environment (`OS_CLOUD_MANAGED=1`) refuses local self-registration, invitations, user creation and import, and sets passwords only on local accounts — self-hosted deployments are unchanged + + Clause-②: yes (narrowing) — one widening and one narrowing. The widening: the package entry now exports `cloudManagedRefusal`, `CLOUD_MANAGED_REFUSAL_CODE`, `CLOUD_MANAGED_REFUSAL_STATUS`, `CLOUD_MANAGED_REFUSAL_MESSAGES` and `CloudManagedDoor`, so a host that mounts its own twin of one of these doors (the cloud's per-environment `set-initial-password`, objectstack-ai/cloud#3169) refuses with the same status, code and message. The narrowing: under the managed mode only, doors that admitted a request now refuse it: every audience posture but `invite_only`, email sign-up, member invitations, admin create-user and import-users, an admin password set on an identity-provider account, and a first password for a caller without platform-admin standing. A process that does not set `OS_CLOUD_MANAGED=1` answers every one of these doors exactly as before. + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, settings key or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (the only export change is five added names), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer plugin-auth's runtime doors give on a process whose environment sets `OS_CLOUD_MANAGED=1`, which the cloud sets on its hosted containers; the remedy is to invite people from the organisation in the cloud console, never a rewrite of a package source or a stored row. `sys_user`, `sys_invitation` and `sys_account` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** for a cloud-managed environment only (a process started with `OS_CLOUD_MANAGED=1`). A hosted environment's people are invited by the organisation that hosts it, in the cloud, and sign in through the cloud's identity provider; authorization (positions, permission sets) stays in the environment. The local doors that would let someone in another way now refuse, and the password doors split by population rather than switching off, so a local break-glass administrator stays reachable when the identity provider is not. Every refusal is `409` with code `RESOURCE_CONFLICT` (in the ADR-0112 envelope on the raw routes, in better-auth's own `{ code, message }` body on `invite-member`) and a message that says what was not done and where it is done instead. + + - **Audience posture.** FROM: any posture booted and patched. TO: under the managed mode every posture but `invite_only` (the default) is refused at construction and on every merged config patch, with a message naming the posture and the fix. + - **Email sign-up.** FROM: open unless `disableSignUp` or SSO-only was set. TO: forced off under the managed mode, and `getPublicConfig()` advertises `disableSignUp: true`. The bootstrap owner's first sign-up still enters, as it does under SSO-only. + - **Invitations.** FROM: `organization/invite-member` created an invitation, and with `resend: true` extended a pending one and sent its e-mail again. TO: `409` on both, refused in the route's `before` hook, ahead of better-auth's body validation, session and permission checks and handler, so no invitation row is written or extended and no e-mail leaves. + - **Admin create-user and import-users.** FROM: created or imported users. TO: `409` after the administrator check (a caller without it still gets `403`), and no user is written. + - **Admin set-user-password.** FROM: set a password on any user. TO: refuses a target whose `source` is `idp_provisioned` with `409`; a local (`env_native`) target is still answered. + - **Set-initial-password.** FROM: any signed-in user without a credential could set one. TO: only a caller with platform-admin standing may; any other signed-in caller gets `409`, and an anonymous caller still gets `401`. + - **New exports.** `cloudManagedRefusal(door)` and the constants behind it, for a host twin of one of these doors. + - **Unchanged:** every door on a process without `OS_CLOUD_MANAGED=1`, including one running SSO-only; existing users, invitations and credentials. + - **The fix for an operator:** nothing on a self-hosted deployment. On a hosted environment, invite people from the organisation in the cloud console; their account arrives on their first sign-in. Keep a local password only for the break-glass administrator. +- 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 + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration of this package is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the vendor library under the auth endpoints: some requests it accepted are now refused, and some refusals change status. The remedies are operational (restart an in-flight sign-in, correct an SSO provider's issuer), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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". +- 425c743: fix(plugin-auth): SSO domain verification accepts the DNS TXT record that request-domain-verification tells the operator to publish (#25111) + + Clause-②: no — the verify door now accepts the record the platform already names, as `@better-auth/sso` designed it; the one record it stops accepting is `undefined=TOKEN`, which only the defect produced and nothing instructs. + Metadata-protocol: none + + `POST /api/v1/auth/admin/sso/request-domain-verification` tells the operator to publish a TXT record named `_better-auth-token-PROVIDER.DOMAIN` with the value `_better-auth-token-PROVIDER=TOKEN`. `POST /api/v1/auth/admin/sso/verify-domain` (and the vendor route behind it, `POST /api/v1/auth/sso/verify-domain`) refused exactly that record with `502 DOMAIN_VERIFICATION_FAILED`. The vendor builds the record it accepts from the verification row's `identifier`, and the storage adapter handed it that row without the column (`sys_verification.identifier` is `internal`), so it expected `undefined=TOKEN`. + + - **Now.** The adapter hands better-auth the row's `identifier` the same way it already hands back `value`. The instructed record verifies on both doors and marks the provider verified. + - **Still refused.** A wrong token, the token under another provider's label, the record published under another provider's name, and `undefined=TOKEN`: each answers `502 DOMAIN_VERIFICATION_FAILED` and leaves the provider unverified. + - **Unchanged.** A record holding only the token is still accepted, as the vendor designed it. The generic data API still omits `identifier` from every `sys_verification` row. +- d609f60: fix(plugin-auth): the Magic Link mail states the lifetime the link really has, five minutes + + Clause-②: no + + Metadata-protocol: none + + The `auth.magic_link` mail said "This link expires in 10 minutes", while the link it carried lived 300 seconds: `magicLink()` was mounted without `expiresIn`, so better-auth's own default applied, and the mail's `expiresInMinutes` was a separate hard-coded `10`. The mount now states `expiresIn: 300` explicitly, and the mail's `expiresInMinutes` is derived from that same value, so every locale of the mail now reads "5 minutes". The link's lifetime does not change. Nothing to do on upgrade; a customised `auth.magic_link` template that hard-codes its own number should read `expiresInMinutes` instead. +- 6b44efa: fix(plugin-auth): an MCP client that registers over OAuth dynamic client registration with a loopback or custom-scheme redirect, and no `application_type`, is registered as a native client instead of being refused + + Clause-②: no (a runtime security-boundary change the maintainer ruled; no published type, export or metadata key moves) + Metadata-protocol: none + + Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register at `POST /api/v1/auth/oauth2/register` with a loopback `http` redirect (`http://localhost:PORT/...`, `http://127.0.0.1:PORT/...`) or a custom-scheme redirect, and send no `application_type`. The OAuth provider used to fill the missing type with `web` and refuse both redirect forms (`invalid_redirect_uri: web clients require https redirect URIs on non-loopback hosts`), so these clients could not connect to the MCP endpoint over OAuth and API keys were the only working path. + + plugin-auth now passes `clientRegistrationDefaultApplicationType: 'infer'` to `@better-auth/oauth-provider`. A dynamic registration that omits `application_type` is classified `native` when any redirect URI uses a non-http(s) scheme or `http` on exactly `localhost`, `127.0.0.1` or `[::1]`, and every redirect URI is then validated by the native rule. Otherwise it stays `web`. What this changes and what it keeps: + + - Accepted as `native`: the body a local MCP client sends (a public client with a loopback or custom-scheme redirect). + - Unchanged: an `application_type` the client sends is never overridden; `https://example.com/cb` alone stays `web`; `http://example.com/cb` and `http://localhost.example.com/cb` stay refused; with dynamic client registration off, every registration is still refused. + - Also accepted as `native`: a confidential registration (`client_secret_basic`, or no `token_endpoint_auth_method`, which the provider fills as `client_secret_basic`) with a loopback or custom-scheme redirect and no `application_type`, which is issued a `client_secret`. The same body was already accepted with `application_type: 'native'` spelled out, so no new client shape becomes registrable. + - PKCE stays required at `/oauth2/authorize` for every one of these clients, and every token still needs the user's sign-in and consent. + + The Setup form's own OAuth application door (`/sys-oauth-application/register`) is not affected. +- 8dfd126: fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The narrowing is one more organization write the last-administrator guard refuses at runtime. --> + + **BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. + + - **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. + - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. + - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. + - **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. + + **What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. +- 829cb77: feat(plugin-auth): a JIT sign-in reads the IdP `locale` claim into the user's language preference, so a hosted environment follows the cloud account language + + Clause-②: no + Metadata-protocol: none + + When an external IdP sign-in creates or refreshes a user just-in-time (the cloud as IdP on a hosted environment through `oidcProviders`, any generic-OAuth or social provider, or a `@better-auth/sso` OIDC provider), the standard OIDC `locale` claim now becomes `sys_user.locale`, the column the console reads as the signed-in user's language and messaging reads per recipient. + + - **First creation:** the claim sets the preference. + - **A later sign-in:** the claim updates it only while the user has not picked a language in the environment itself. A local choice wins and is never overwritten; clearing it hands the column back to the IdP. + - **An unknown or unsupported claim:** nothing is stored (an IdP-written value is cleared), so the deployment default applies. The claim is closed against the locales the i18n service reports (`i18n.supportedLocales` when the app declares them), matched exact, then case-insensitive, then by base language (`en-US` → `en`, `ja` → `ja-JP`), and the supported spelling is stored. The underscore spelling (`zh_CN`) reads as the tag it stands for. + - **No claim:** nothing changes. + + What the IdP last wrote is recorded per user in `sys_verification` (identifier `idp-locale:` plus the user id), so an IdP-written value can be told apart from a local choice; no schema changes. Applying the claim never fails a sign-in: a store that refuses the write is reported at `warn` and the user keeps the language they had. +- 09e07c2: fix(plugin-auth)!: under `single`, no slug change makes another organization the Default Organization — re-slugging to `default` captured the platform-admin anchor + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is a runtime write: a `sys_organization` slug changed to `default` under `single`. --> + + **BREAKING** for a `single` deployment that holds several organizations, none slugged `default`, and re-slugs one of them to `default`. Under `single` the Default Organization is the organization slugged `default`, else the only one, and its `admin_full_access` grant confers platform administrator standing (ADR-0131 D5). A re-slug to `default` therefore made that organization the Default Organization, and every holder of a grant it owned a platform administrator: an organization owner could do it through better-auth's `POST /auth/organization/update`, and a seed or a plugin through an engine update. + + - **FROM:** a slug changed to `default` on any organization was admitted, as long as the last-administrator guard found an administrator left standing. **TO:** under `single` it is admitted only on the organization that is already the Default Organization (the sole organization taking the slug the boot gives it). Any other organization is refused `403 PERMISSION_DENIED` by the last-administrator guard's `sys_organization` `beforeUpdate` hook, for every engine write, `isSystem` included, so better-auth's `organization/update` answers it too. A read the guard cannot complete refuses as well. + - **Removed, with no in-place replacement: moving the Default Organization.** FROM: with several organizations, an operator could move `default` from one to another in two writes (the current one re-slugged away, then another re-slugged to `default`). TO: the second write is refused, because it is exactly the capture above, whoever makes it. An atomic swap was never possible (the slug is unique). + - **Stranding.** Re-slugging the Default Organization AWAY while other organizations exist is still admitted when an administrator stays standing (the last-administrator guard's update half, unchanged). After it, no organization names the Default Organization, so no organization can take `default`, the previous holder included, until the store holds one organization again or declares a wall. Do not re-slug a Default Organization away on a `single` deployment that holds several organizations. + - **Unchanged:** a slug changed to any other value; every slug change under a walled posture (`isolated`, `group`), where no grant anchors and a slug is just a slug; the data API, whose user-context PATCH and update-mode import were already refused a `slug` by the ADR-0092 identity write guard. + + **What an operator does.** Keep another slug. To move the anchor, move the grant rather than the slug: an `admin_full_access` grant re-owned by the Default Organization anchors, and a grant owned elsewhere does not. To name a Default Organization on a `single` deployment that holds several organizations, hold one organization (merge the others into it; the sole organization is the Default Organization and may then take the slug `default`), or declare a walled posture (`OS_TENANCY_POSTURE=group` or `isolated`), as the tenancy organization census already prescribes. +- f4112a7: feat(plugin-auth): `GET /api/v1/auth/config` serves `features.cloudManaged` + + Clause-②: yes (widening) + Metadata-protocol: none + + The public auth config's `features` gains `cloudManaged`, a boolean read from `isCloudManaged()` in `@objectstack/core` (true when the process runs with `OS_CLOUD_MANAGED=1`). It is `false` on every other deployment. The console reads it to hide the doors that add people by hand (invite, create user, set password) in a cloud-managed environment, whose people arrive from the organisation's identity provider. + + What to do: nothing. The payload only gains a key. +- b3034ee: refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security + + Clause-②: yes (widening) + Metadata-protocol: none + + Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before. + + - **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing: + - behind a wall, no grant does; + - under `single`, the grant owned by the Default Organization does; + - an organization-less grant never does, and neither does any grant when no Default Organization can be named. + + Three readers call it: + - the authorization resolver passes the memoised Default Organization; + - the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it; + - the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion. + + Each reader keeps its own normalizer for a grant row's organization. + - **The organization-reach rule (ADR-0131 D8/D9).** New exports: + - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`; + - `organizationReachAdmits(reach, rowOrganizationId)`; + - `organizationReachRowOwner(reach)`; + - `organizationIdOf(value)`; + - `UNOWNED_ORGANIZATION_REACH`; + - the types `OrganizationReach` and `OrganizationReachFacts`. + + The rule decides whose `sys_setting` rows a caller may draw on: + - the caller's own organization; + - for a caller naming none where no wall stands, the organization D9 derives; + - nothing behind a wall; + - rows with no organization, only in a composition that registers no organization object. + + `SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts: + - the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller; + - the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults. + + These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader. + + No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action. +- 3306cad: feat: Create User can grant a permission set in the same step, judged by the same delegated-administration gate as Setup's grant form + + Clause-②: yes (widening: `POST /api/v1/auth/admin/create-user` and the `sys_user` `create_user` action accept an optional `permissionSet`; a request without it is answered exactly as before) + Metadata-protocol: none + + A user created with no permission set signed in to a screen of refusals. Setup → Users → Create User now offers an optional **Permission Set** (the set's machine name, checked against the security catalog). + + - **How the grant is written.** The route inserts the `sys_user_permission_set` row under the calling administrator's OWN execution context, never the system's. The security middleware, the ADR-0090 D12 delegated-administration gate (which stamps `granted_by`) and the catalog name check judge it exactly as they judge the grant form. The route's platform-admin gate decides who may create a user; it never decides what that user may be granted. + - **One step, one outcome.** The account and the grant run in one engine transaction. A refused grant answers the refusal's own status and code (403 `PERMISSION_DENIED` from the gate, 400 `VALIDATION_FAILED` with its `fields` for a name the catalog does not hold), and no user is created. A datasource whose driver has no `beginTransaction` runs the two writes unatomically, the engine's declared limit (ADR-0119 D1). + - **Refused before anything is written:** a `permissionSet` that is not a string (400), a caller whose own context does not resolve (403), and a host that wires no grant seam (501). + - **Response.** The success body carries `permissionSet` beside the user, and the result dialog shows it. + - **Cloud-managed environments are unchanged.** `create_user` stays hidden while `features.cloudManaged` is on. +- 431327b: Clause-②: no + Metadata-protocol: none + + The session activity heartbeat now runs on every deployment. `sys_session.last_activity_at` is "the last request on this session", and Setup → Sessions shows it so an administrator can tell two of one user's sessions apart. Until now it was written only while `sessionIdleTimeoutMinutes` was above 0, so on a default deployment (no idle or absolute limit) the Last Activity column stayed empty. + + It is still written at most once a minute per session. When neither the idle nor the absolute limit is configured, the `sys_session` read that feeds it is throttled too: a per-process memo, per session and bounded in size, skips the read for 60 seconds after one. With a limit configured, every request still reads the session row, as enforcement needs. +- 8a49e37: fix(plugin-auth): the last-administrator guard answers a read it could not complete with 503 `SERVICE_UNAVAILABLE`, the code the sibling guards on the same doors answer their read faults with + + Clause-②: no + Metadata-protocol: none + + The last-administrator guard fails closed: when a read its verdict depends on throws, it refuses the write. That refusal answered 403 `PERMISSION_DENIED`, the same code as a refusal on the merits, so a caller could not tell "you may not" from "try again" by the code. The organization slug guard (`auth-manager.ts`) and the organization delete admission already answer their read faults with 503 `SERVICE_UNAVAILABLE`. + + - **Every half of the guard, one shape.** The shared fail-closed envelope and the Default Organization slug-capture rule now throw the same read fault: `code: 'SERVICE_UNAVAILABLE'`, `status: 503`, the read's own failure on `cause`. The write is still refused and nothing is written. A refusal on the merits, and an enumeration that completed but returned more rows than the guard can judge, keep 403 `PERMISSION_DENIED` unchanged. + - **The data API** answers the read fault 503 `SERVICE_UNAVAILABLE` with the server-fault message, as it does every declared 5xx. + - **better-auth's endpoints** (`organization/update`, the admin ban and remove-user endpoints, and every other endpoint that writes through the adapter) answer it 503 `SERVICE_UNAVAILABLE`. The adapter now maps any engine error that declares `status: 503` and `code: 'SERVICE_UNAVAILABLE'` to that answer; before, such an error reached better-auth's router as an unhandled 500 with an empty body. The driver's message is not relayed. + - **`organization/delete`** of the Default Organization answers the guard's read fault 503 `SERVICE_UNAVAILABLE`, the code the admission's own read faults answer with. A refusal on the merits there stays 403 `PERMISSION_DENIED`. + - **For a client:** branch on the code. `SERVICE_UNAVAILABLE` means retry once the data store is reachable; `PERMISSION_DENIED` means the write itself is refused. +- Updated dependencies [448374d] +- 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 [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0603bfa] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [f802213] +- Updated dependencies [86d2360] +- Updated dependencies [2069b81] +- 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 [c24203f] +- Updated dependencies [28c9a2c] +- Updated dependencies [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [a39389e] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4f97aeb] +- Updated dependencies [c4b49c3] +- Updated dependencies [284e048] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [1b3b65e] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [1f78b64] +- Updated dependencies [bc5daf0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..f8b5d85718b 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/plugin-dev +## 18.1.0 + +### Patch Changes + +- Updated dependencies [54df640] +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [abc25a3] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [f802213] +- Updated dependencies [86d2360] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [0f1eeee] +- Updated dependencies [f46ef4d] +- Updated dependencies [3782b2d] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [9340181] +- Updated dependencies [9340181] +- Updated dependencies [2e242bb] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4f97aeb] +- Updated dependencies [c4b49c3] +- Updated dependencies [db9c02a] +- Updated dependencies [c6bdcac] +- Updated dependencies [02ad31a] +- Updated dependencies [0148071] +- Updated dependencies [03966ce] +- Updated dependencies [1b3b65e] +- Updated dependencies [331613c] +- Updated dependencies [5801a4f] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [829cb77] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [431327b] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] + - @objectstack/plugin-security@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/runtime@18.1.0 + - @objectstack/objectql@18.1.0 + - @objectstack/service-storage@18.1.0 + - @objectstack/driver-memory@18.1.0 + - @objectstack/plugin-auth@18.1.0 + - @objectstack/plugin-hono-server@18.1.0 + - @objectstack/rest@18.1.0 + - @objectstack/account@18.1.0 + - @objectstack/setup@18.1.0 + - @objectstack/service-i18n@18.1.0 + - @objectstack/service-realtime@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..007f0e1e975 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,135 @@ # @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. +- 2aaee3b: feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered + + Clause-②: yes + Metadata-protocol: none + + The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered. + + - **When the explicit refusal stays, unchanged.** + - The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered. + - The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused. + - The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account. + - **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart. + - **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32. + - **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder. + - **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step. + - **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap. + - **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail. +- 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 [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..3eedadde214 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,93 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) a narrowing of CORS admission at three doors plus a boot refusal of one more configuration class: no authorable metadata key, spelling, type or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. The remedy is a deployment variable or the door's own origin option, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is an admission verdict, not an interface, a type or an export retirement (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..88b20900642 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,54 @@ # @objectstack/plugin-pinyin-search +## 18.1.0 + +### Patch Changes + +- c8f4c43: fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number + + Clause-②: yes (widening) + Metadata-protocol: none + + A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. + + - **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. + - **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. + - **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. + - **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. + - **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. +- Updated dependencies [94842ba] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [9340181] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [504d8b3] +- Updated dependencies [3a440b0] +- Updated dependencies [8dfd126] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [db1c7cc] +- Updated dependencies [b3034ee] +- Updated dependencies [ee63b47] +- Updated dependencies [27b3f1f] + - @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..0429e9e7979 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,525 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data change this relies on is the operator's ownership ceremony, `os migrate organization-ownership`, which an upgraded database must have passed before it boots. --> + + **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-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security plugin's master-detail write gate gives for one shape of write to a `controlled_by_parent` detail: it now asks the master-edit question a by-id update or delete already asks, and refuses where that question refuses. The remedy is a grant on the master or a narrower write, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security plugin's master-detail write gate gives for one shape of write to a `controlled_by_parent` detail: it now asks the master-edit question of every master that write involves, and refuses where that question refuses for any of them. The remedy is a grant on the master or a narrower write, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security plugin's master-detail write gate gives for two shapes of write to a `controlled_by_parent` detail: a batch create now asks the master-edit question of the master each of its rows names, and refuses where that question refuses for any of them; and an update that empties a master reference required-field validation does not judge is refused. The remedy is a grant on the master, a batch kept within masters the caller may edit, or a master reference kept populated, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the security middleware gives an in-process caller that builds an on-behalf-of execution context in an organization the delegator does not belong to; the remedy is to build that context in an organization the delegator is a member of, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 + +- 54df640: feat: at boot, an administrator is told when a package upgrade renamed, removed or collided with an access item the customer switched off, cloned or authored + + Clause-②: yes (widening: plugin-sharing's exported `SharingRuleService` gains `listDeclaredRules()`; the report itself refuses nothing and adds no key, code or setting) + Metadata-protocol: none + + ADR-0126 §6 wall 3 says no upgrade un-makes a customer's choice. A switch-off, a clone or a sharing rule row is kept by name, so a vendor rename made a switched-off item active again under its new name, and nothing told anyone. At `kernel:bootstrapped`, beside the catalog reference report, `SecurityPlugin` now compares the customer's access state with the registry this boot registered. It covers sharing rules, permission sets, positions and capabilities, and lists: + + - **Orphans:** activation-ledger rows (`position`, `permission`, `sharing_rule`) whose name the registry no longer resolves. + - **Suspected re-enablements:** a switched-off orphan whose recorded `package_id` is P, with every name of the same type P now ships that no ledger row covers. A permission-set candidate names the positions that bind it. + - **Sharing-rule collisions, per organization:** a `sys_sharing_rule` row holding a declared rule's name, so the declared rule is served nowhere in that scope. A position, permission-set or capability collision is already refused at registration or at boot. + - **Broken references in the customer's items:** a permission set authored in this system naming an object, field or capability the registry lacks, and a `sys_sharing_rule` row naming an object or criteria field the registry lacks. + - **Field denies a customer set lacks:** a package-shipped set that denies a field, beside a set authored in this system that grants the same object with no entry for that field. An unlisted field is visible. + + The findings are logged once per category, as now. They are also raised to the people who can act on them: + + - **Under `single`,** through the existing notification ingress (`messaging.emit()`, topic `security.access_upgrade`, severity `warning`), which writes a Notification Events row in Setup and the in-app inbox. It reaches the Default Organization's `admin_full_access` holders and any `OS_PLATFORM_OWNER_EMAIL` address. + - The `dedupKey` is the finding set's digest, so an unchanged set notifies once, not at every boot. + - With no messaging service, nobody to tell, or an emit the ingress refuses, one log line says the findings reached no administrator and names the fix for that cause. + - The notification is not mandatory: the ingress has no per-emit flag, so a recipient's mute hides it. To make it mute-proof, add `security.access_upgrade` to the messaging plugin's existing `mandatoryTopics` option. + - **Under `group` or `isolated`,** to the platform operator alone, by one `error` line addressed to the operator. The ledger and the catalog are deployment-level, so only the operator can act. The notification family is organization-scoped, and a walled write with no organization is refused (ADR-0131 D9); stamping an organization would hand the finding to a tenant. So no notification is attempted, and no organization is told. + + Two limits follow from ADR-0126 §9, under which a clone records no base, and from the bare `(metadata_type, name)` ledger key: + + - A re-enablement is **suspected** and may list several same-package candidates. Nothing records which name replaced which. + - The field-deny check reads "grants the same object", not "copied from that set", so it may name an unrelated set. It is a review item. + + "The old name carrying a clone" is carried by the switch-off half. The sanctioned ceremony is clone plus switch-off, and a clone whose base stays on is not a choice a rename un-makes. A read that fails stops the report for that boot, and nothing is judged from an unread store. + + - **`@objectstack/plugin-sharing`:** `SharingRuleService.listDeclaredRules()` answers every declared rule with its `packageId`, or `null` when the service reads no declarations. +- 448374d: feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) + + Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening). + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. --> + + **BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. + + - **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. + - **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. + - **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. + - **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. + - **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). + - **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. + - **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. + + The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. +- 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. +- 0b8bce4: fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before. + - **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face. + - **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500. + - **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves. +- 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. +- 9340181: fix(plugin-security): step 3.7's tenant write wall is composed by one method, and the write preview asks it about each row + + Clause-②: yes (widening) + + Metadata-protocol: none + + The engine's write preview (`validate()`, the import dry run) runs no middleware, so it never heard the Layer 0 tenant wall's verdict on a row's `organization_id`. + + - **One spelling of the wall.** Step 3.7's judgement (the no-active-organization pre-test with its delegator leg, the supplied-value forge guard, the emptied organization on update, the #24784 / #24821 no-active-organization insert arms, and whether the wall judges the organization) is composed by one method, `composeWriteTenantWall`. The middleware's payload half and its stored-image seam both call it. Behaviour, refusals and log lines are unchanged. + - **New: `SecurityPlugin.writeWallVerdict(object, operation, context, rows)`**, registered on the engine as `registerWriteWallVerdict`. It answers each row from that same method: admitted (and whether the wall judges its organization), or refused with the write's `PermissionDeniedError` code and message. It answers `null` for a system context, a context with no user, and a caller the context prologue refuses before the wall is reached. On an engine without the seam the plugin logs one warning at start and the preview keeps its old answer. +- 0148071: fix(plugin-security)!: under a `controlled_by_parent` declaration whose one master reference is `readonly` or `system`, every write that sets that reference is refused (ADR-0055 §1) + + Clause-②: no (narrowing) + + Metadata-protocol: none + + <!-- adr-0087: not-required (already-registered cbp-master-relation-tiers-retired) registered before this change, and it covers it: that entry's stored-row listing (`os migrate meta --stored`) already reports this declaration as `master-not-writable`, with the prescription below. Nothing new moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed. What changes is the answer the security plugin's write gate gives on an object that reached the registry with a declaration the parse already refuses. --> + + **BREAKING** for a caller that relied on such a write landing. A non-protocol change, shipped as `patch`. + + ADR-0055 §1 derives a `controlled_by_parent` detail's access from exactly one required `master_detail` field that every write of a detail supplies. The parse refuses that master when it is declared `readonly: true` or `system: true`, so an object with it reaches the runtime only through a door that does not parse: a raw `registerObject` or `registerApp`, a `defineStack` call with `strict: false`, or a stored object the boot registers under its `[metadata_spec_invalid]` diagnostic. On such an object the security plugin's write gate let writes that set the master reference through, and a detail could end up outside the master its access derives from, readable by nobody, while the write reported success. + + - **Before.** An insert under that declaration was written when it named a master. An update could set the reference. + - **Now.** Every write that sets the reference answers `INVALID_METADATA` (422) and writes nothing, before any master is asked: every insert, single or batch, whether or not it names a master, and every update whose body carries the reference key, whatever the value, `null` included. The message is the parse's own refusal of the declaration, prefixed with the write it refused, so it names the object, the field, each flag and the fix. It is the same for every caller the gate judges. + - **Unchanged.** + - The rows already stored: the security plugin still derives their read access from that field, so they keep their master and are read as before. + - A delete, and an update whose body leaves the reference key out. + - System contexts, every object declared with an ordinary required master, and every object that does not declare `controlled_by_parent`. + + **What to do.** Fix the declaration as the parse says: remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`). +- 331613c: fix(plugin-security)!: the raw-SQL read scope (`getReadFilter`) answers the deny sentinel for a delegated context the operation prologue refuses — the delegator's membership must back the organization the read runs in, on a walled posture + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the read scope the security service answers an in-process caller that builds an on-behalf-of execution context in an organization the delegator does not belong to; the remedy is to build that context in an organization the delegator is a member of, never a rewrite of a package source or a stored row. --> + + **BREAKING** (the security service's read scope narrows for one context shape). ADR-0021 D-C makes the raw-SQL read scope the find path's scope, and the provider's own contract is agreement with the engine middleware. Under `isolated` or `group`, the delegated-principal prologue already refuses every operation of an on-behalf-of context whose active organization the delegator's membership does not back (ADR-0090 D10, `vetOrganizationClaim`). `getReadFilter` did not ask that question, so for the same context it answered a composed read scope where the find path refuses. It now asks it, once, beside the dangling-delegator refusal it already mirrored. + + - **FROM:** under `isolated` or `group`, `getReadFilter` for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of answered a read scope composed from the two principals' layers, where the engine's find refuses the same context. + - **TO:** it answers `RLS_DENY_FILTER` itself (the spec's sentinel, by reference), so a door that bypasses the engine reads zero rows for that context, as the engine's find refuses it. + - **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator (still the deny sentinel) 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 keep their scope. + - **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand and hands it to the security service or to the analytics service must set its `tenantId` to an organization the delegating user is a member of, or leave it unset. +- 5801a4f: fix(plugin-security)!: the delegation probes, field projections, explanation and narrowing diagnostic answer the refusal the operation prologue makes for a delegated context whose delegator is not a member of the organization it runs in, on a walled posture + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-security/src/explain-engine.ts#ExplainEngineDeps) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. Two things change. The security service's probes answer an in-process caller that builds an on-behalf-of execution context in an organization the delegator does not belong to with the refusal its operations already get; the remedy is to build that context in an organization the delegator is a member of, never a rewrite of a package source or a stored row. And the published runtime interface `ExplainEngineDeps`, which has no metadata surface, gains a required member; the channel that reaches an embedder who builds it is the compiler at their own call site. --> + + **BREAKING** (the security service's answers narrow for one context shape, and `ExplainEngineDeps` gains a required member). Under `isolated` or `group`, the delegated-principal prologue refuses every operation of an on-behalf-of context whose active organization the delegator's own membership does not back (ADR-0090 D10, `vetOrganizationClaim`), and the raw-SQL read scope answers that refusal too. The other readers in this package that resolve a delegator on their own did not ask that question, so for the same context they answered an admission the operation refuses. Each now asks it at the prologue's point, through one helper (`delegatedOrganizationRefusal`) that the prologue and the read scope ask as well. + + - **FROM:** under `isolated` or `group`, for an execution context carrying `onBehalfOf` and an active organization the delegator is not a member of, `canReadObject`, `canWriteObject` and `canExport` could answer `true`; `getReadableFields`, `getMetadataReadableFields`, `getWritableFields` and `getQueryableFields` answered the intersected field list; the access explanation reported the D10 intersection and could answer `allowed: true`; `describeDelegationNarrowing` could report a narrowing; and the explanation's per-record write gate asked the sharing service as the agent and the delegator. + - **TO:** the three probes answer `false`; the four field projections answer `[]`; the explanation answers `allowed: false`, its principal and object_crud layers `denies`, naming the organization the delegator is not a member of; `describeDelegationNarrowing` answers `{ narrowed: false }`, as for a dangling delegator; and the per-record write gate refuses without asking the sharing service. + - **Unchanged:** the `single` posture, an on-behalf-of context with no active organization, an on-behalf-of context in an organization the delegator is a member of, a context without `onBehalfOf`, a dangling delegator, 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 keep every answer. + - **The fix for an in-process producer:** code that builds an `ExecutionContext` with `onBehalfOf` by hand must set its `tenantId` to an organization the delegating user is a member of, or leave it unset. + - **`ExplainEngineDeps`:** an embedder that builds the explain engine's dependencies itself passes the deployment's tenancy posture as `tenancyPosture`, the value the security plugin's enforcement runs under. The compiler names the missing member. +- 24360dc: fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. + - **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. + - **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". + - **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. +- 8dfd126: fix(core,plugin-auth)!: the last-admin guard judges a `sys_organization` insert that moves the Default Organization, and one definition of the Default Organization is exported from core + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The narrowing is one more organization write the last-administrator guard refuses at runtime. --> + + **BREAKING** for a `single`-posture deployment whose only organization is not slugged `default` and whose last administrator holds nothing but the Default Organization's `admin_full_access` grant. + + - **`registerLastAdminGuard` (`@objectstack/plugin-auth`) now judges a `sys_organization` insert.** Under `single` the Default Organization is the one slugged `default`, else the only one, and its `admin_full_access` grant is what makes the platform administrator one (ADR-0131 D5). Adding an organization can move it with no grant touched: a second organization beside a sole one with another slug leaves none, and a new one slugged `default` takes its place. + - FROM: the insert was admitted. The administrator lost platform standing in silence, and the guard then admitted a ban or delete of that same administrator, because it no longer counted anyone. + - TO: the insert is refused (`PERMISSION_DENIED`, 403, on `sys_organization`) when it would leave nobody who can sign in and administer. An organization owner or admin, an `OS_PLATFORM_OWNER_EMAIL` administrator, or a Default Organization slugged `default` (which a new organization cannot move) admits it, as on the update and delete halves. Under a wall no grant anchors, so the insert is admitted and costs no reads. + - **One definition of the Default Organization (`@objectstack/core`).** New exports: `findDefaultOrganization(read)` (the rule, over a read the caller supplies), `DEFAULT_ORGANIZATION_SLUG`, and `readDefaultOrganizationId(ql)` (the resolver's own read, which fails loud on an unreachable store). plugin-auth's boot invariant, default-organization bootstrap, tenancy service and last-admin guard, plugin-security's `resolveDefaultOrganizationId`, and the ownership ceremony's plan now name the organization through it. Each keeps its own read policy: whether a failed read throws or reads as none, the guard's simulation of a pending write, and the ceremony's raw SQL. Two edge answers align with the resolver's. A `default`-slugged row with a blank id now falls through to the only-organization reading in the ceremony's plan. A numeric id is now read as its string in plugin-security's reader. + + **What an operator does.** Before you create a second organization on a `single` deployment whose only organization has another slug, either give that organization the slug `default` (while it is the only one, that changes nothing else), or make sure another account can administer: an organization owner or admin, or an address in `OS_PLATFORM_OWNER_EMAIL`. +- b3034ee: refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security + + Clause-②: yes (widening) + Metadata-protocol: none + + Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before. + + - **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing: + - behind a wall, no grant does; + - under `single`, the grant owned by the Default Organization does; + - an organization-less grant never does, and neither does any grant when no Default Organization can be named. + + Three readers call it: + - the authorization resolver passes the memoised Default Organization; + - the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it; + - the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion. + + Each reader keeps its own normalizer for a grant row's organization. + - **The organization-reach rule (ADR-0131 D8/D9).** New exports: + - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`; + - `organizationReachAdmits(reach, rowOrganizationId)`; + - `organizationReachRowOwner(reach)`; + - `organizationIdOf(value)`; + - `UNOWNED_ORGANIZATION_REACH`; + - the types `OrganizationReach` and `OrganizationReachFacts`. + + The rule decides whose `sys_setting` rows a caller may draw on: + - the caller's own organization; + - for a caller naming none where no wall stands, the organization D9 derives; + - nothing behind a wall; + - rows with no organization, only in a composition that registers no organization object. + + `SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts: + - the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller; + - the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults. + + These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader. + + No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action. +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- 27b3f1f: feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both + + Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`) + Metadata-protocol: none + + Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it. + + - **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read. + - **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force. + - **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing. + - **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`. + - **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values. + - **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..19ed6bfe0b3 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,198 @@ # @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. + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. --> + + **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 + +- 54df640: feat: at boot, an administrator is told when a package upgrade renamed, removed or collided with an access item the customer switched off, cloned or authored + + Clause-②: yes (widening: plugin-sharing's exported `SharingRuleService` gains `listDeclaredRules()`; the report itself refuses nothing and adds no key, code or setting) + Metadata-protocol: none + + ADR-0126 §6 wall 3 says no upgrade un-makes a customer's choice. A switch-off, a clone or a sharing rule row is kept by name, so a vendor rename made a switched-off item active again under its new name, and nothing told anyone. At `kernel:bootstrapped`, beside the catalog reference report, `SecurityPlugin` now compares the customer's access state with the registry this boot registered. It covers sharing rules, permission sets, positions and capabilities, and lists: + + - **Orphans:** activation-ledger rows (`position`, `permission`, `sharing_rule`) whose name the registry no longer resolves. + - **Suspected re-enablements:** a switched-off orphan whose recorded `package_id` is P, with every name of the same type P now ships that no ledger row covers. A permission-set candidate names the positions that bind it. + - **Sharing-rule collisions, per organization:** a `sys_sharing_rule` row holding a declared rule's name, so the declared rule is served nowhere in that scope. A position, permission-set or capability collision is already refused at registration or at boot. + - **Broken references in the customer's items:** a permission set authored in this system naming an object, field or capability the registry lacks, and a `sys_sharing_rule` row naming an object or criteria field the registry lacks. + - **Field denies a customer set lacks:** a package-shipped set that denies a field, beside a set authored in this system that grants the same object with no entry for that field. An unlisted field is visible. + + The findings are logged once per category, as now. They are also raised to the people who can act on them: + + - **Under `single`,** through the existing notification ingress (`messaging.emit()`, topic `security.access_upgrade`, severity `warning`), which writes a Notification Events row in Setup and the in-app inbox. It reaches the Default Organization's `admin_full_access` holders and any `OS_PLATFORM_OWNER_EMAIL` address. + - The `dedupKey` is the finding set's digest, so an unchanged set notifies once, not at every boot. + - With no messaging service, nobody to tell, or an emit the ingress refuses, one log line says the findings reached no administrator and names the fix for that cause. + - The notification is not mandatory: the ingress has no per-emit flag, so a recipient's mute hides it. To make it mute-proof, add `security.access_upgrade` to the messaging plugin's existing `mandatoryTopics` option. + - **Under `group` or `isolated`,** to the platform operator alone, by one `error` line addressed to the operator. The ledger and the catalog are deployment-level, so only the operator can act. The notification family is organization-scoped, and a walled write with no organization is refused (ADR-0131 D9); stamping an organization would hand the finding to a tenant. So no notification is attempted, and no organization is told. + + Two limits follow from ADR-0126 §9, under which a clone records no base, and from the bare `(metadata_type, name)` ledger key: + + - A re-enablement is **suspected** and may list several same-package candidates. Nothing records which name replaced which. + - The field-deny check reads "grants the same object", not "copied from that set", so it may name an unrelated set. It is a review item. + + "The old name carrying a clone" is carried by the switch-off half. The sanctioned ceremony is clone plus switch-off, and a clone whose base stays on is not a choice a rename un-makes. A read that fails stops the report for that boot, and nothing is judged from an unread store. + + - **`@objectstack/plugin-sharing`:** `SharingRuleService.listDeclaredRules()` answers every declared rule with its `packageId`, or `null` when the service reads no declarations. +- 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. +- 60b42cd: fix(spec,plugin-sharing): the `DataEvent.organizationId` description and the sharing-rule Organization help stop describing organization-less rows that 18.x no longer writes + + Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves) + Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes) + + - **`DataEvent.organizationId` (`@objectstack/spec/api`).** The description said every event on a `single`-posture deployment carries no organization, because nothing stamps the column there. Since 18.x the engine stamps every tenant row on a single-organization deployment with its Default Organization, so those events carry it. The key is now described as absent in two cases: a row of an object outside the wall, and every row of a composition that registers no organization object. The schema itself is unchanged (`z.string().min(1).optional()`). + - **`sys_sharing_rule.organization_id` (`@objectstack/plugin-sharing`).** The Setup help said "null = global". A stored rule always carries its organization; a rule that applies to every organization is a declared rule. The help now reads "The organization this rule belongs to.", with the translated locales updated to match. +- 24360dc: fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. + - **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. + - **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". + - **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. +- bdf762c: fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/platform-objects`:** `sys_business_unit.kind` declared its options as bare strings, so each label equalled its stored value and the form offered `cost_center`. The options now carry labels (Company, Division, Department, Office, Cost Center). The stored values are unchanged. + - **`@objectstack/plugin-sharing`:** `sys_sharing_rule.recipient_type` and `access_level` declared bare-string options too. They now carry labels (User, Team, Business unit, Position, Business unit and subordinates, Field; Read, Read and edit), which are the names the Recipient Type help text already used. `sys_record_share` declares the same recipient and access vocabulary, so its `recipient_type` (User, Group, Position, Business unit and subordinates) and `access_level` options carry the same labels. The stored values are unchanged. + - **`@objectstack/spec`:** the `autoMigrate` key of the SQLite, PostgreSQL and MySQL driver configs had no JSON-Schema `title`, so the New datasource form labelled the field with the raw key `autoMigrate`. It is now titled "Auto-migrate schema". + + The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [688052e] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [9340181] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..9be6dbebf5d 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,137 @@ # @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 [448374d] +- 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 [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0603bfa] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [c24203f] +- Updated dependencies [28c9a2c] +- Updated dependencies [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [a39389e] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [284e048] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [1f78b64] +- Updated dependencies [bc5daf0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..6156248311a 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,201 @@ # @objectstack/dogfood +## 0.0.49 + +### Patch Changes + +- Updated dependencies [54df640] +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [72a302a] +- Updated dependencies [ba69b16] +- Updated dependencies [865d2c8] +- Updated dependencies [865d2c8] +- Updated dependencies [2941a19] +- Updated dependencies [abc25a3] +- Updated dependencies [e282f32] +- Updated dependencies [3d8960c] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [ea08ce5] +- Updated dependencies [19e5338] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0603bfa] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [b811c73] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [688052e] +- Updated dependencies [5d27d32] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- 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 [c24203f] +- Updated dependencies [d6a5fa3] +- Updated dependencies [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [4bf079c] +- Updated dependencies [0f1eeee] +- Updated dependencies [f46ef4d] +- Updated dependencies [3782b2d] +- Updated dependencies [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [a39389e] +- Updated dependencies [9340181] +- Updated dependencies [9340181] +- Updated dependencies [2e242bb] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [db9c02a] +- Updated dependencies [284e048] +- Updated dependencies [c6bdcac] +- Updated dependencies [02ad31a] +- Updated dependencies [0148071] +- Updated dependencies [03966ce] +- Updated dependencies [331613c] +- Updated dependencies [5801a4f] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [8010b64] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [1f78b64] +- Updated dependencies [bc5daf0] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [72fad92] +- Updated dependencies [829cb77] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [431327b] +- Updated dependencies [97aa34e] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/plugin-security@18.1.0 + - @objectstack/plugin-sharing@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/runtime@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/service-analytics@18.1.0 + - @objectstack/plugin-pinyin-search@18.1.0 + - @objectstack/mcp@18.1.0 + - @objectstack/formula@18.1.0 + - @objectstack/verify@18.1.0 + - @objectstack/example-showcase@0.3.23 + - @objectstack/example-crm@4.0.101 + - @objectstack/example-multi-package@0.0.8 + - @objectstack/connector-mcp@18.1.0 + - @objectstack/connector-openapi@18.1.0 + - @objectstack/connector-rest@18.1.0 + - @objectstack/metadata@18.1.0 + - @objectstack/trigger-record-change@18.1.0 + - @objectstack/trigger-schedule@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..90a465ba699 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,63 @@ # @objectstack/downstream-contract +## 0.0.47 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..45030248b52 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,20 @@ # @objectstack/http-conformance +## 0.1.9 + +### Patch Changes + +- Updated dependencies [94842ba] +- Updated dependencies [644e4b2] +- Updated dependencies [dfe60e3] +- Updated dependencies [8dfd126] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [db1c7cc] +- Updated dependencies [b3034ee] +- Updated dependencies [27b3f1f] + - @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..56471bdd694 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,181 @@ # @objectstack/rest +## 18.1.0 + +### Minor Changes + +- eddffc3: feat(spec): a form view's `title` is translated through `objects.<object>._views.<view>.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.<view>` 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.<object>._views.<view>.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.<key>` 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.<view>.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 +- c4b49c3: fix(rest)!: the CSV export writes a text cell its consumer would interpret in a form that consumer reads as text, and the CSV import reverses exactly that + + Clause-②: no (narrowing) — the CSV export serves different bytes for some text cells, and the CSV import parse reads a cell of the same shape differently; nothing is widened. + Metadata-protocol: none (a REST export's bytes and a REST import's parse; nothing a metadata author writes changes) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the bytes one REST export format serves for some text cells and how the CSV import parse reads a cell of that same shape; stored rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the CSV export's bytes change for some text cells, and the CSV import reads a user-authored cell of the same shape differently), shipped as `patch` because the metadata protocol does not move. + + The CSV export (`GET /api/v1/data/:object/export?format=csv`) wrote every text cell exactly as stored, so a cell whose leading character the program the file is opened in interprets was read there as something other than text. The rule below is defined once, in `csvTextCellNeedsMark` (`packages/rest/src/export-format.ts`), and both directions read it. + + - **The export.** A CSV text cell whose first character is one of a small fixed set that its consumer interprets is written with one apostrophe (U+0027) in front of its content, before the usual RFC-4180 quoting. A text cell is a string in a column whose field is not declared numeric, boolean, date or datetime, a header label, or a reference field's display title; with no field metadata (the raw path) every string cell is one. A cell made only of digits, spaces and the characters a signed number or a phone number is written with is never marked, so phone numbers and signed amounts are byte-identical. + - **The import.** `parseCsvToRows` removes exactly one leading apostrophe from a cell, header row included, when the rest of the cell is one the export would mark. It serves the sync import (`POST /api/v1/data/:object/import`), the async import job (`POST /api/v1/data/:object/import/jobs`) and plugin-auth's admin user import (`POST /api/v1/auth/admin/import-users`), with an inline mapping or a named one. So a CSV exported and imported back returns the stored value, with the one exception in the next point. + - **A cell that begins with an apostrophe** followed by a cell the export would mark now imports without that apostrophe; the file cannot tell it from an exported one. That holds for a user-authored cell, and for a stored text value of that shape, which the export writes unchanged, so it comes back without its leading apostrophe. A cell beginning with an apostrophe followed by anything else imports unchanged. + - **Measured.** A value beginning with TAB or CR is marked; the import removes the mark, and the default `trimWhitespace: true` then trims the leading TAB or CR exactly as it did before. With `trimWhitespace: false` it returns exactly. + - **Unchanged.** Every other cell; numeric, boolean, date and datetime cells; the xlsx and json export formats; json and xlsx import payloads. + - **What to do.** Nothing for a file that goes back through these import doors. A program that reads the CSV export some other way and needs the stored text applies the same reversal: remove one leading apostrophe from a cell whose rest the export would mark, by the rule in this repository's `packages/rest/src/export-format.ts`. +- 1b3b65e: feat(rest)!: `GET {base}/docs`, the OpenAPI document's HTML viewer page, is retired; `GET {base}/openapi.json` stays + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) A runtime route retirement in RestServer.registerOpenApiEndpoints, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped: the page was an HTML answer on one REST path, and `objectstack migrate meta` has nothing to rewrite. The machine-readable document, its `api.enableOpenApi` gate and its answer are unchanged. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this route and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only / runtime-export-retired). --> + + **BREAKING** (a published route stops being served), shipped as `patch` because it is a non-protocol change: it changes what a REST caller is served, not what a metadata author can write. + + `RestServer.registerOpenApiEndpoints` mounted two routes on every base: the OpenAPI document and an HTML viewer page for it. The viewer page is retired on both route shapes, the unscoped `{base}/docs` and the environment-scoped `{base}/environments/:environmentId/docs`. Nothing read it, and the API origin serves no HTML viewer. + + - **Not served now:** `GET {base}/docs` has no route. A request to it gets the host's ordinary not-found answer, the same one any unmounted path gets: on the standard server (`@objectstack/plugin-hono-server`) that is `404` with code `ENDPOINT_NOT_FOUND`, for a signed-in caller and an anonymous one alike. + - **Unchanged:** `GET {base}/openapi.json` on both route shapes, its `api.enableOpenApi` gate, its anonymous `401` and the document a signed-in caller receives. The document no longer lists the `/docs` path, because it describes only mounted routes. + - `api.enableOpenApi` now gates one route, the document. + + What changes for you. If you opened the page, load the document from `GET {base}/openapi.json` into an OpenAPI viewer of your choice. +- 27b3f1f: feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both + + Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`) + Metadata-protocol: none + + Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it. + + - **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read. + - **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force. + - **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing. + - **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`. + - **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values. + - **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/metadata-core@18.1.0 + - @objectstack/platform-objects@18.1.0 + - @objectstack/metadata@18.1.0 + - @objectstack/observability@18.1.0 + - @objectstack/service-package@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..ed9473d0d16 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,309 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer one runtime REST door gives a caller who asks it to adopt the environment's package-less items into a package the deployment ships; the remedy is to adopt them into a package the caller owns, never a rewrite of a package source or a stored row. Rows an earlier adoption already bound to a read-only package are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data change this relies on is the operator's ownership ceremony, `os migrate organization-ownership`, which an upgraded database must have passed before it boots. --> + + **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. +- 9cfc575: fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message` + + Clause-②: yes (widening) + Metadata-protocol: none + + Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text. + + - **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text. + - **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built. + - **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union. + + **For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none. +- a1e51d6: fix(runtime)!: the `/mcp` bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition two runtime readers consult: the HTTP MCP bridge's object discovery and the data doors' API-exposure gate now read the protocol's merged view, the definition REST already serves and enforces, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the data doors' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests a data door serves, not what a metadata author can write. + + The `/mcp` bridge answered `list_objects` and `describe_object` from the `metadata` service, and the API-exposure gate every runtime data door asks (`callData`) read the same service. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package or a plugin registers during the boot. So `list_objects` answered a short list as a complete `totalCount`, `describe_object` answered "not found" for every such object, and the gate read the missing copy as "no declaration" and served it. REST listed and refused the same objects. + + - **Before.** On that kernel, an object a package or a plugin registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way. + - **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does. + - **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb on `/mcp`, and every well-formed request on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. On that door, as on REST, a body that is not a record is still answered `400 VALIDATION_FAILED` first. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing. + - **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before. + - **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. An agent that read aggregates of a disabled package's objects through `aggregate_records` was relying on the same kind of defect: re-enable the package to restore access, as REST says. +- fb7501b: fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read an object's actions from the listing `list_objects` serves, so a package object's actions are listed and run on every kernel, and a disabled package's are omitted as its objects are + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which object definitions the HTTP MCP bridge collects object-embedded actions from: the protocol's merged listing, the one its `list_objects` / `describe_object` tools and `GET /api/v1/meta/object` already serve, instead of the metadata service's copy. The functions that change (`collectActionDeclarations`, `resolveActionByName`, `invokeBusinessAction`, `resumeActionRun`) are not published by `index.ts`, and each gains only an optional trailing input. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled package's actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write. + + The HTTP MCP bridge (`POST /api/v1/mcp`) collected the actions declared on objects from the `metadata` service's `listObjects()`. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_actions` listed none of such a package's actions and `run_action` answered `Action 'NAME' not found on object 'OBJECT'`, while `list_objects` listed the object since #25120 and REST's `POST /api/v1/actions/OBJECT/NAME` ran the action. + + - **Before.** On that kernel, an action declared on an object a package registered during the boot (inside the object, or as a top-level action bound to it) was missing from `list_actions`; `run_action` answered `Action 'NAME' not found on object 'OBJECT'` (`Action 'NAME' not found` without `objectName`); and `resume_run` refused a run of such a flow action `403`, because no flow action it could see targets that flow. + - **Now.** The three tools collect object-embedded actions from the object definitions `list_objects` lists: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. Such an action is listed, resolved and dispatched behind the same gates as before (`ai.exposed`, `requiredPermissions`, the activation switch, the declared params, `ai.requiresConfirmation`), and runs as REST's `/actions` door runs it. + - **Also changed.** A disabled package's actions, where the `metadata` service still held a copy of its objects (every kernel without an `environmentId`, and a marketplace install on any kernel), were listed and run: a script action's body ran, a declarative update was refused at the record load. They are now omitted from `list_actions`, `run_action` answers `Action 'NAME' not found on object 'OBJECT'` for them, and `resume_run` refuses their runs `403`, as `describe_object` answers their objects "not found". When the protocol's merged listing throws, the three tools fail with that error, as `list_objects` does, instead of reading the `metadata` service's listing. + - **Unchanged.** Actions on objects that already had a copy are listed and run as before. Standalone `action` rows are still read from the `metadata` service (`loadMany('action')`), and an object-embedded declaration still wins over one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. REST's `/actions` door is untouched. + - **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose objects the rest of the platform no longer serves: re-enable the package to restore it. +- 258f4e6: fix(runtime)!: REST's `POST /api/v1/actions/OBJECT/NAME` refuses an action on an object whose owning package is disabled `404 OBJECT_PACKAGE_DISABLED`, the data plane's answer for that object, before any handler runs + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is one answer of the `/actions` door: an invocation on an object whose owning package is disabled is refused with the protocol's own `404 OBJECT_PACKAGE_DISABLED`, read through the by-name object read the data verbs' exposure gate already makes. The one function added (`refuseDisabledPackageObject` in `action-execution.ts`) is not published by `index.ts`. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the `/actions` door stops running a disabled package's object actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. + + Disabling a package takes its objects off every door: `/data` refuses them `404 OBJECT_PACKAGE_DISABLED`, `GET /api/v1/meta/object` omits them, and the MCP `run_action` tool answers "not found" for their actions. REST's `/actions` door did not ask. It resolved the action's declaration from the engine's schema registry, where a disabled package's objects stay registered, and dispatched. + + - **Before.** On every kernel shape, `POST /api/v1/actions/OBJECT/NAME` on an object of a disabled package ran a script action's handler and answered `200` with its result; a declarative update was refused only at the record load; an action gated by `requiredPermissions` answered a caller without them `403`; and a name nothing declares answered the "no declaration" `404`. + - **Now.** Each of those is refused `404 OBJECT_PACKAGE_DISABLED`, with the error the data plane raises for the object, before any gate reads the action's declaration and before any record load or dispatch. The anonymous baseline still answers first. + - **Unchanged.** Actions on objects of an enabled package run as before, as does every gate after this one. Re-enabling the package restores the actions at once: nothing records the refusal. An object-less action (`POST /api/v1/actions/global/NAME`) names no object and is not affected. The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) is untouched. + - **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it. +- 882372d: fix(runtime)!: the `/mcp` bridge's `list_actions`, `run_action` and `resume_run` read the actions no object embeds from the merged `action` listing, so a package's object-less action and an environment-authored one are listed and run, and a disabled package's is omitted + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where the HTTP MCP bridge reads the standalone `action` items from: the protocol's merged listing, the one `GET /api/v1/meta/action` already serves, instead of the metadata service's `loadMany('action')`. The functions that change (`collectActionDeclarations`, `resolveActionByName`, `invokeBusinessAction`, `resumeActionRun`) are not published by `index.ts`, and each gains only an optional trailing input. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (`list_actions`, `run_action` and `resume_run` stop serving a disabled compiled-artifact package's object-less actions), shipped as `patch`: it changes which reads the MCP bridge answers, not what a metadata author can write. + + The HTTP MCP bridge (`POST /api/v1/mcp`) collected the `action` items that no object embeds, the object-less (`global`) actions among them, from the `metadata` service's `loadMany('action')`. That call reads the service's loaders alone. They hold the compiled artifact's actions, but not an action a package registers through the `manifest` service (a capability plugin during the boot, or a marketplace install) and not an environment-authored action saved through `PUT /api/v1/meta/action/NAME`. Those live in the registry and in `sys_metadata`, which `GET /api/v1/meta/action` reads, and REST's `POST /api/v1/actions/global/NAME` ran them. + + - **Before.** On every kernel, such an action was missing from `list_actions`, `run_action` answered `Action 'NAME' not found` (`Action 'NAME' not found on object 'global'` when scoped), and `resume_run` refused a run of such a flow action `403`. + - **Now.** The three tools collect those items from the protocol's merged `action` listing, the one `GET /api/v1/meta/action` serves, through the same read the object listing uses. Such an action is listed, resolved and dispatched behind the same gates as before (`ai.exposed`, `requiredPermissions`, the activation switch, the declared params, `ai.requiresConfirmation`, a registered handler for a script action), and runs as REST's `/actions` door runs it. + - **Also changed.** A compiled-artifact package's object-less action stayed in the loader after the package was disabled, so it was still listed and run. It is now omitted from `list_actions`, and `run_action` answers `Action 'NAME' not found` for it, as the package's objects are omitted since #25120. When the merged `action` listing throws, the three tools fail with that error instead of reading the `metadata` service. + - **Unchanged.** The compiled artifact's actions are listed and run as before. An object-embedded declaration still wins over a standalone one with the same object and name. A host with no `protocol` service, or one without the merged listing, keeps reading `loadMany('action')`. REST's `/actions` door is untouched. + - **What to do.** Nothing. An agent that ran a disabled package's action through MCP was running an action whose package the rest of the platform no longer serves: re-enable the package to restore it. +- eee2f0d: fix(runtime)!: REST's `POST /api/v1/actions/global/NAME` answers an object-less action of a disabled package the "no declaration" `404` a name nothing declares gets, before any handler runs + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object or action definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where the `/actions` door reads a standalone action's declaration: through the protocol's by-name `action` read, which already withholds a disabled package's item, so such an action resolves to no declaration. No function is added or published; `resolveRouteActionDeclaration` keeps its signature. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (the `/actions` door stops running a disabled package's object-less actions), shipped as `patch`: it changes what one door answers, not what a metadata author can write. + + Disabling a package withholds its items: `GET /api/v1/meta/action/NAME` answers `404`, and the MCP `run_action` tool answers "not found". The `/actions` door refuses an action on an object of a disabled package `404 OBJECT_PACKAGE_DISABLED`, but an object-less action names no object to ask about. It resolved a standalone declaration from the engine registry or the metadata service, where a disabled package's items stay, and dispatched. + + - **Before.** On every kernel shape, `POST /api/v1/actions/global/NAME` (and its empty-segment spelling `/actions//NAME`) on a disabled package's top-level action with no `objectName` ran its handler and answered `200` with its result. So did an enabled object's route that resolves the same object-less action, and the package's top-level action bound to another, enabled package's object. + - **Now.** The door reads a standalone action through the protocol's by-name `action` read (`GET /api/v1/meta/action/NAME`'s read, on its execution face), which withholds a disabled package's item. Each request above answers the "no declaration" `404`, the same status, code and message, but for the name, that a name nothing declares gets, before any gate, record load or handler. A by-name read that fails is the metadata plane failing to answer: the door's existing `503`, never a fall-back to a copy. An action on an object of a disabled package still answers `404 OBJECT_PACKAGE_DISABLED` first. + - **Also.** The activation door (`POST /api/v1/actions/_activation/OBJECT/NAME`) resolves through the same function, so a disabled package's standalone action is answered there by that door's own "no declaration" `404` too, where a flip wrote a ledger row before. Re-enable the package to switch it. + - **Unchanged.** An enabled package's object-less actions, and an environment-authored one (no owning package), run as before, through a disable of any package. Re-enabling the package restores its actions at once: nothing records the answer. A host whose protocol has no by-name read resolves from the registry and the metadata service, as before. + - **What to do.** Nothing. A caller that ran a disabled package's action through REST was running code of a package the rest of the platform no longer serves: re-enable the package to restore it. +- c898bd8: feat: Setup → API Keys → Create Key offers the key's expiry, and a date past the policy's maximum is refused with the policy named + + Clause-②: yes + Metadata-protocol: none + + Create Key asked only for a name, so every key silently got the default life the Authentication API key policy sets, and the list then showed an expiry the admin never chose. The `sys_api_key` action `create_api_key` now declares an optional `expires_at` param, the second body key `POST /api/v1/keys` already reads. Its help text says what an empty field means (the policy's Default key lifetime, 90 days unless changed) and that a date later than the policy's Maximum key lifetime is refused. The param has no literal default on purpose: an action param default is submitted verbatim, so a fixed date there would override the deployment's policy instead of following it. The reveal dialog now shows the expiry the key actually got, beside the key. + + The route behaviour is unchanged: an empty field gets the policy's default life, and a date past the maximum is refused `400 API_KEY_EXPIRY_TOO_LONG`, never clamped. That refusal's message now names the setting that decided it ("the Authentication API key policy's Maximum key lifetime is N days") next to the latest date allowed. +- Updated dependencies [54df640] +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [72a302a] +- Updated dependencies [865d2c8] +- Updated dependencies [2941a19] +- Updated dependencies [abc25a3] +- Updated dependencies [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [ea08ce5] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [b811c73] +- Updated dependencies [f802213] +- Updated dependencies [86d2360] +- Updated dependencies [688052e] +- Updated dependencies [5d27d32] +- Updated dependencies [6b656bf] +- Updated dependencies [2069b81] +- Updated dependencies [5d66021] +- 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 [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [4bf079c] +- Updated dependencies [8c73398] +- Updated dependencies [f46ef4d] +- Updated dependencies [3782b2d] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [9340181] +- Updated dependencies [9340181] +- Updated dependencies [2e242bb] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4f97aeb] +- Updated dependencies [c4b49c3] +- Updated dependencies [db9c02a] +- Updated dependencies [c6bdcac] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [0148071] +- Updated dependencies [03966ce] +- Updated dependencies [1b3b65e] +- Updated dependencies [331613c] +- Updated dependencies [5801a4f] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [17f3c65] +- Updated dependencies [b62f959] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [829cb77] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [69913f8] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [431327b] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] + - @objectstack/plugin-security@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/core@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/driver-memory@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-sqlite-wasm@18.1.0 + - @objectstack/formula@18.1.0 + - @objectstack/metadata@18.1.0 + - @objectstack/observability@18.1.0 + - @objectstack/service-cluster@18.1.0 + - @objectstack/service-i18n@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..d8588322c47 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,91 @@ # Changelog — @objectstack/service-analytics +## 18.1.0 + +### Patch Changes + +- 0b8bce4: fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before. + - **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face. + - **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500. + - **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves. +- 830a63a: feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System" + + Clause-②: no + Metadata-protocol: none + + A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor: + + - **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes. + - **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before. + - **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System". +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..f691aa76c43 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,136 @@ # @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<DroppedFieldsEvent['reason'], …>` 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. +- 9cfc575: fix(service-automation,runtime): a flow whose data node is refused answers its caller with the refusal structured — `details.innerRefusal` and the producer-marked `userMessage` on every door's `400 FLOW_FAILED` — beside an unchanged `message` + + Clause-②: yes (widening) + Metadata-protocol: none + + Ruling B′ on #24914 (the maintainer's 「同意」, decision batch #379 item 5), the emission half of the contract `@objectstack/spec` declared in #24996. A record save the platform refuses reaches a record form as `code`, `message` and the producer-marked `userMessage`; the same refusal met by a flow's data node used to be flattened into one string (`Node 'ID' failed: create_record(OBJECT) failed: …`), so a screen flow could only show its user that developer text. + + - **`service-automation`.** The catch arms of `get_record`, `create_record`, `update_record` and `delete_record` read the refusal off the thrown error: the producer-marked `userMessage` (through `declaredUserMessage`) and, for a throw that declared a 4xx answer, `{ code, message }` with `code` in the WIRE spelling the data door answers for the same throw — `UNIQUE_VIOLATION` for a unique refusal, `VALIDATION_FAILED`, `PERMISSION_DENIED`, or the member an app hook's declared status names. `NodeExecutionResult` gains the two optional members, and the engine carries them from the failing node to `AutomationResult.userMessage` / `.innerRefusal` on `execute()`'s failure exit, the `resumeInternal` stranded exit and the retry exits. A fault (no declared answer, or a declared 5xx) carries neither member; nothing is read from message text. + - **`runtime`.** The trigger, resume, `/actions` and declared-endpoint doors build their `400 FLOW_FAILED` details through one builder typed as `FlowFailureDetailsSchema` (`errorMessage?`, `summary?`, `innerRefusal?`) and put the run's `userMessage` at the envelope's top level (`ApiErrorSchema.userMessage`). The MCP `resume_run` bridge carries both as the REST resume door serves them. `DomainHandlerDeps.error` gains an optional fourth argument, `extra: { userMessage? }`, the declared slot the doors use; a call without it builds the envelope it always built. + - **Unchanged.** Every `error` / `message` text, byte for byte — the node's, the engine's prefix, the stored run, approvals and each door's wording. The in-flow `$error.code` keeps the engine's in-process `DUPLICATE_RECORD`. `error.code` stays `FLOW_FAILED`, and `AutomationResult.code` stays the closed union. + + **For consumers.** A flow runner can render a refused run the way a record form renders a refused save: the top-level `userMessage` when present, then `details.innerRefusal`, then `message`. A client that does not read the new members keeps its current behaviour. A container that rewords a child's failure into its own (`try_catch` without a catch region, `subflow`) does not forward the members yet; the run then carries none. +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [17f3c65] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [9d96b2b] +- Updated dependencies [5f30f00] +- Updated dependencies [69913f8] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..9e279c3c0ed 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/service-cache +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..c9ce12b31c3 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,64 @@ # @objectstack/service-cluster-redis +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..f7e49ca5d31 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/service-cluster +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..45c21dcfe9c 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,295 @@ # @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 `<file>.corrupt-<timestamp>`, 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 '<name>': connect failed — <reason>`. `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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes it is raised with grows by one), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` gives for a SQLite file that is not a database: a rejection where it resolved. The remedy is the datasource's file, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 `<file>.corrupt-<timestamp>` 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 `<file>.corrupt-<timestamp>` 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`SqliteDatabaseFileError` keeps its shape; only the set of codes and paths it is raised for grows), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer `SqlDriver.connect()` and the read-only judgement give for three SQLite files a datasource cannot use: a rejection where each resolved. The remedy is the datasource's file or its path, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 `<file>.corrupt-<timestamp>` 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. +- 2354058: feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired --> + + `encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed. + + **BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). + + - **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them. + - **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words. + - **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server | + | `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | + | `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest | + + The fix in one line: delete `encryptionKey` from every turso datasource. + + ### The ADR-0087 disposition + + - **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value. + - A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand. + - The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it. + - `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`. + + ### Stored rows stay redacted + + A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike. + + ### Blast radius + + Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero. + +### Patch Changes + +- 72a302a: fix(service-datasource)!: Sync (remote tables, object draft, import, catalog refresh) refuses a managed datasource with the existing `400`, because ADR-0015 §4.2 reserves federation for a datasource whose `schemaMode` is not `managed` + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer four runtime REST doors give a caller who asks them to list, draft, import or catalog the tables of a datasource the platform owns; the remedy is to sync from a datasource declared `external` or `validate-only`, never a rewrite of a package source or a stored row. Objects an earlier import already bound to a managed datasource are not touched. --> + + **BREAKING** (four REST doors' accept set narrows). ADR-0015 §1 puts a federated object on a datasource whose `schemaMode` is not `managed`, and §4.2 allows `object.external` only there; `managed` is the spec's default. The Sync doors refused only the platform's primary datasource (`default`). Every other managed datasource answered, including the runtime datasources an administrator creates in Setup → Datasources, which are saved with no `schemaMode` and so are managed: their tables were listed, drafted and imported as federated objects. The engine's write gate (ADR-0015 §5.3) gates only non-managed datasources, so nothing stood behind such an import. One guard in the federation service now refuses before anything is introspected, and every door that reaches the service inherits it. + + - **FROM:** on a datasource other than `default` whose `schemaMode` is `managed`, declared or by default, `GET /api/v1/datasources/:name/remote-tables` and `GET /api/v1/datasources/:name/external/tables` answered `200` with its tables; `POST /api/v1/datasources/:name/object-draft` and `POST /api/v1/datasources/:name/external/tables/:remote/draft` answered `200` with a federated object draft bound to it; `POST /api/v1/datasources/:name/external/tables/:remote/import` answered `201` and saved that object; `POST /api/v1/datasources/:name/external/refresh-catalog` answered `200` and stored an `external_catalog` for it. A name with no datasource definition was introspected whenever a driver was registered under it. + - **TO:** each of those requests answers `400` with the code its door already serves for a refusal, `EXTERNAL_DATASOURCE_ERROR` (the import: `EXTERNAL_IMPORT_ERROR`), and a message naming the rule and the remedy. Nothing is introspected, drafted, saved or stored. A name with no datasource definition gets the same refusal: nothing declares it a federation source. + - **Unchanged:** `default` keeps its own refusal and message. A datasource declared `schemaMode: 'external'` or `'validate-only'` lists, drafts, imports and refreshes as before. Test (`POST /api/v1/datasources/:name/test`) and validation (`POST /api/v1/datasources/:name/external/validate`) answer as before; validation already skipped a managed datasource, and it now asks the same predicate the guard asks. No status code or error code is added. + - **The fix for an integrator:** sync from a datasource the platform does not own. Declare it with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings; for a runtime datasource, send both in `PATCH /api/v1/datasources/:name`. A managed datasource's tables are the platform's own objects: define objects on it directly instead of importing them. +- 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. +- 27dc53b: fix(driver-sqlite-wasm)!: a file wasm SQLite cannot read is refused at connect, naming the file, and left as it was found, instead of being renamed and replaced with an empty database (#25019) + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed (`WasmSqliteConnection` and `SqliteWasmDriver` keep their shapes; the private quarantine method is gone), so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer wasm SQLite's connect gives for a database file it cannot read: a rejection naming the file where it renamed the file and resolved over an empty database. The remedy is the datasource's file, never a rewrite of a package source or a stored row. --> + + **BREAKING** (wasm SQLite's connect accept set narrows). `WasmSqliteConnection.open()` used to answer a file sql.js could not read by renaming it to `<file>.corrupt-<timestamp>` and starting an empty database in its place, with one warning. That self-heal was written for wasm SQLite's own torn writes, which the atomic flush has since closed; what still reached it was an operator's file on a declared datasource. A datasource declared `driver: 'sqlite-wasm'` over such a file read `available` over the empty replacement, in development and production alike, and a declared `sqlite` datasource on a development boot whose native addon cannot load stepped down to wasm SQLite and met the same rename. The native driver already refuses the same files at connect. + + - **FROM:** a file sql.js cannot read (plain text, another format, a database truncated inside its header or short of the pages its schema names) was moved to `<file>.corrupt-<timestamp>`; `connect()` resolved over an empty database, and the datasource read `available`. On a development boot whose native addon cannot load, `resolveSqliteDriver` (`@objectstack/service-datasource`) returned that wasm driver, and when wasm SQLite failed for any reason it went on to the in-memory driver. + - **TO:** `SqliteWasmDriver.connect()` rejects with the native driver's `SqliteDatabaseFileError`: `code: 'SQLITE_NOTADB'` for bytes that are not a database, `code: 'SQLITE_CORRUPT'` for a database it cannot read, `filename`, and a message naming the file and how to move it aside. Nothing is renamed, nothing is written beside it, and a stale write-ahead log beside a refused file stays where it is. `WasmSqliteConnection.open()` rejects with the same code and `filename`. `resolveSqliteDriver` re-throws that refusal at the wasm rung instead of trying the in-memory driver, so the datasource's connect fails and its connection state says so. Any other wasm failure, sql.js itself not loading first among them, still goes on to the in-memory driver. + - **Unchanged:** a real database opens and answers; an absent file is created; an empty file opens as an empty database; a native addon that cannot load over a real or absent file still steps down to wasm SQLite. + - **The fix for an operator:** restore the database from a backup, or move the unreadable file aside yourself (`mv '<file>' '<file>.unreadable'`) and start again: an absent file is created empty on the next connect. A development server whose own wasm image is damaged no longer starts itself clean; this is that step, done by hand. +- 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. + + <!-- adr-0087: registered turso-config-local-encryption-key-refused --> +- 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 + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration of this package is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the vendor library under the auth endpoints: some requests it accepted are now refused, and some refusals change status. The remedies are operational (restart an in-flight sign-in, correct an SSO provider's issuer), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- Updated dependencies [5d66021] +- 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 [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [8c73398] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4e2c42f] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [3a440b0] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/driver-sql@18.1.0 + - @objectstack/driver-turso@18.1.0 + - @objectstack/driver-memory@18.1.0 + - @objectstack/metadata-core@18.1.0 + - @objectstack/driver-sqlite-wasm@18.1.0 + - @objectstack/driver-mongodb@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..7ed7657d614 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/service-i18n +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..b6991624dd5 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,90 @@ # @objectstack/service-job +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..7fe23ac37d6 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/service-knowledge +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..f6208e6e27e 100644 --- a/packages/services/service-mailbox/CHANGELOG.md +++ b/packages/services/service-mailbox/CHANGELOG.md @@ -1,5 +1,92 @@ # @objectstack/service-mailbox +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..a9917221e51 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,265 @@ # @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) + + <!-- adr-0087: registered sys-notification-subscription-retired --> + + **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.<object>.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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. The one definition that changes is a validation rule on `sys_notification_template`, a platform object this package ships and owns, and what narrows is the write accept set of that object's rows, which are admin-authored data rather than metadata: no ADR-0087 conversion reaches a stored data row. The remedy is an operator's edit or delete of the row, never a rewrite of a metadata source, and this change rewrites or deletes no stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. An authored template body's own markup renders exactly as before; what changes is how the renderer encodes a hole's VALUE in the html and mjml faces. The remedy is an author's choice — put the markup in the template body instead of a payload value — never a rewrite of a stored row or a package source, and stored `sys_notification_template` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. +- e282f32: fix(service-messaging,metadata-protocol)!: a notification preference's User is a `sys_user` reference, so the record title names the person and the write door refuses an id no user holds; the default for every user is an empty User instead of `'*'` + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or exported declaration is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is a platform object this package ships and owns, `sys_notification_preference`: its `user_id` column becomes a `sys_user` lookup, and what narrows is the write accept set of that object's rows, which are admin-authored data rather than metadata. No ADR-0087 conversion reaches a stored data row; the stored rows that spelled the retired `'*'` are rewritten by this package itself at boot, and the one-default-per-key index is a runtime migration in `@objectstack/metadata-protocol`. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (a platform object's write accept set narrows, and one stored value is rewritten). It changes what a data writer can store in `sys_notification_preference`, not what a metadata author can write, so it ships as `patch` with this banner. + + - **FROM:** `sys_notification_preference.user_id` was a required text column holding a user id or the literal `'*'`, which meant "the default for every user". Any string was stored: a mistyped id matched nobody, silently. The record title (Setup → Notification Preferences) read the raw id, for example `ISu3jHPIrdgD45L1rrC2FuE7DIcsk2ap · urgent_task · inbox`. Two defaults for every user could be stored for one topic and channel in one organization, and which one applied was unspecified. + - **TO:** `user_id` is an optional `sys_user` lookup (ADR-0118 D1: no sentinel in a user column). An empty User is the default for every user, and a user's own row still overrides it. + - A write that names an id no user holds, `'*'` included, is refused: the REST data door answers `400` with `code: 'VALIDATION_FAILED'` and a finding on `user_id` with `code: 'reference_not_found'`. + - The title names the person: their name, or their email where the name is blank, then the topic and the channel. A row with an empty User reads `All users · TOPIC · CHANNEL`. A row whose User this reader cannot read, or that no longer exists, has no title rather than a misleading one. + - Deleting a user deletes that user's preference rows (`deleteBehavior: 'cascade'`). Under the lookup default (`set_null`) the person's own mute would have become the default for every user. + - At every boot (`kernel:ready`), the messaging service rewrites each stored `user_id = '*'` to an empty User, in every organization. A second run changes nothing. A `'*'` row whose topic and channel already have a default with an empty User is left as stored and reported once at `error`, naming the row; it is never deleted for you. + - On SQLite and PostgreSQL, `@objectstack/metadata-protocol` rebuilds the index `uniq_sys_notification_preference_a22d7d27` at `kernel:ready` as `(organization_id, COALESCE(user_id, ''), topic, channel)`, so a second default for one topic and channel in one organization is refused (`409`). If existing rows already break that rule, it refuses to migrate: the previous index stays, no row is touched, and the boot log and `os migrate duplicates` list the rows. MySQL/MariaDB and driver-memory keep the declared index, which does not constrain empty Users, and the boot log says so on MySQL. + - **Unchanged:** `topic = '*'` and `channel = '*'` are still the topic and channel wildcards. Most-specific-wins resolution, mandatory topics, quiet hours and digests behave as before. A row for a real user is read, matched and titled as before, except for the title text. The physical column is still a `varchar(255)`, so no DDL change is needed for it. + - **The fix for an operator:** nothing, normally; the boot rewrites the old `'*'` rows. If the boot log reports a `'*'` row that shares its topic and channel with a default whose User is already empty, delete the one that should not apply and restart. If it reports that the one-default-per-key index could not be built because two such defaults exist, list them with `os migrate duplicates`, delete one, and restart. A script or import that wrote `user_id: '*'` writes an empty `user_id` (omit it, or send `null`) for the default for every user. +- 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. +- c24203f: fix(service-messaging): the heading of a notification digest of two or more items is rendered in its recipient's language, not as the English "You have N notifications" for everyone + + Clause-②: no + Metadata-protocol: none + + - **Before.** A recipient whose notification preference digests `daily` or `weekly` received each window's digest headed "You have N notifications" in English, whatever their language, even when every item under it was rendered in their language. + - **Now.** The heading is keyed (`messages.notificationDigestTitle`, with a `{{count}}` hole) in `@objectstack/service-messaging`'s own translation bundle, in en, zh-CN, ja-JP and es-ES. It is rendered at delivery by the same keyed-text renderer as the digest's items (`EmitInput.keyedText`), in the same locale read: the recipient's `sys_user.locale`, then the deployment default. A zh-CN recipient reads "您有 3 条通知". + - **Unchanged.** The English sentence stays the literal fallback, byte for byte: with no `i18n` service, with no renderer composed, or when neither the recipient's language nor the deployment default has a pack. A one-item digest is still titled by its item. No export, option or type changes. + - **Cost.** With an `i18n` service registered, each digest of two or more items now reads its recipient's locale once, even when none of its items is keyed, because its heading always is. +- 284e048: fix(service-messaging)!: `sys_notification_template` refuses an html or mjml body that places a hole where the renderer's encoding is not right for it + + An html or mjml notification template body HTML-escapes every hole's value. That encoding is right in two placements only: element text, and a quoted plain-text attribute value. A hole anywhere else (an attribute read as a URL, an event handler, a style, an attribute holding markup, a meta element attribute, an unquoted attribute value, inside a tag, a raw-text element, a comment, or SVG / MathML content) received an encoding that does not fit where it lands. Such a body is now refused when it is saved. A hole counts as supported only when every way an HTML parser can read the body places it in element text or a quoted plain-text attribute value. The check is the template object's own save hooks, judged over the stored row overlaid with the write, so a row stored before this change that breaks the rule is refused on any edit that leaves it breaking it: frozen, not bricked. It is still read, listed and deleted, and it keeps rendering and delivering as before. No stored row is rewritten. + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the write accept set of `sys_notification_template` rows, a platform object this package ships and owns, whose rows are admin-authored data rather than metadata: no ADR-0087 conversion reaches a stored data row. The remedy is an author's edit of the row, moving a hole or changing the format, never a rewrite of a metadata source, and this change rewrites or deletes no stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** (a platform object's write accept set narrows, stored rows included). It ships as `patch` because the metadata protocol does not move. + + - **FROM:** a create, update, import row or bulk-update row that left a `sys_notification_template` row with format `html` or `mjml` and a hole outside element text and quoted plain-text attribute values was accepted and stored. A row already stored that way took every edit. + - **TO:** that write is refused. The REST data door answers `400` with `code: 'VALIDATION_FAILED'` and one finding at `body` (`code: 'invalid_value'`) that names each refused hole and its placement. An import commit fails that row with `field: 'body'` and `code: 'invalid_value'`. 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 writer is judged, system context included. + - **Unchanged:** `markdown` and `text` bodies, whose holes render verbatim; html and mjml bodies whose every hole sits in element text or a quoted plain-text attribute value; reading, listing and deleting any row; and how every row renders and is delivered. No error code is added. + - **The fix for an author:** move each refused hole into element text or a quoted plain-text attribute value, or set the format to `text` or `markdown`. A link whose address comes from a value belongs in an email template (`email_template`), which declares that variable as a URL; name it in the notify step's `template`. +- 1f78b64: fix(service-messaging)!: retire `renderNotification`, the render export that takes a notification template body with no save moment + + The hole-placement rule for notification templates is judged where a template is saved: the `sys_notification_template` save hooks. `renderNotification` took a template body inline and rendered it at once, with no save moment, so the rule could not see it. It leaves the published entry of `@objectstack/service-messaging`. Nothing in this repository imported it from that entry. The renderer stays inside the package: the email and sms channels still render every stored template through it, unchanged. + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/service-messaging#renderNotification) One runtime function leaves a published package entry; no key of any metadata schema, no object definition and no stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A TypeScript consumer meets the break at its own import, and the line below names the door to use instead. --> + + **BREAKING** (a runtime export is removed). It ships as `patch` because the metadata protocol does not move. There is no staged window. + + - FROM `renderNotification` TO a `sys_notification_template` row for the topic, channel and locale, which the delivering channel renders when the notification is emitted. + + Unchanged: `interpolate` (the plain-text face, which the placement rule judges at no door), `NotificationTemplateStore`, `TEMPLATE_OBJECT`, `DEFAULT_LOCALE` and the template types; and how every stored template renders. +- bc5daf0: fix(service-messaging): a notification delivery's title names the person it is for, not their raw user id (#25194) + + Clause-②: yes (widening) + Metadata-protocol: none + + The record title of a `sys_notification_delivery` row (Setup → Notification Deliveries) read `channel → recipient_id`, so a delivery to a user showed their raw user id. It now reads `channel → Person`: the user's name, or their email where the name is blank. A delivery to an address no user holds — an email kept as given, a phone number — is titled by that address, as before. + + The delivery object gains one optional field, `recipient_user_id` (Recipient User), a `sys_user` reference the title reads through. `emit()` sets it when the recipient is a user's id, from one `sys_user` read per emit, and leaves it empty for an address. Deleting the user empties it; the delivery stays, titled by the raw recipient. `recipient_id` keeps its type and meaning (the address: a user id, an email or a phone number), and is now labelled **Recipient**. + + Rows written before this change carry no `recipient_user_id` and keep their old title until the 90-day window ages them out. Nothing is migrated, and no delivery is routed by the new field. +- ee63b47: fix: Setup field help is written for administrators, and the two access-review fields nothing uses yet are hidden from the Setup forms + + Clause-②: no + Metadata-protocol: none + + A Setup pass met field help written for the platform's developers: "Foreign key to sys_user.id", "NULL only on a grant written before the column existed", "via isGrantActive", "(P3 digest middleware)", "Owned by objectql (better-auth is oblivious to this column)", "LifecycleService (Reaper / Rotator / Archiver)" and ADR numbers. Each now says what the field means and what to do with it, in every shipped locale (`en` regenerated from the source; `zh-CN`, `ja-JP` and `es-ES` translated): + + - the user, OAuth application, session and refresh-token fields of the permission-set and position assignments and of the OAuth token, consent and client-resource objects, and the package-version field of stored metadata; + - the permission set picked on an assignment; + - the start time of an approval delegation; + - the digest cadence and quiet hours of a notification preference, which now say how each value behaves; + - the AI Access and Locale fields of a user; + - the Data Lifecycle settings page and its retention overrides. + + `Last Certified At` and `Certified By` on `sys_user_permission_set` and `sys_user_position` are reserved for a future access-review feature and nothing writes or reads them. They are now `hidden: true`, so the Setup forms and detail pages no longer show them. The columns and any stored values stay. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..6a4da50641d 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/service-package +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..02d666bdba3 100644 --- a/packages/services/service-print/CHANGELOG.md +++ b/packages/services/service-print/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/service-print +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [0f1eeee] +- Updated dependencies [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..63cded66a6d 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,90 @@ # @objectstack/service-queue +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..dccefec0576 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,90 @@ # @objectstack/service-realtime +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..4398773ec0e 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,242 @@ # @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) + + <!-- adr-0087: not-required (no-migration-prescription) The two retired keys were settings values, not metadata: only the platform-shipped brandingSettingsManifest declared them (only platform code declares a settings manifest), SettingsManifestSchema and every other spec declaration are unchanged, and a value stored for one of them lives in sys_setting (runtime state, ADR-0007), not sys_metadata, and is kept as it is. Nothing read either key in objectstack or in objectui at the console pin, so no consumer has anything to rewrite and objectstack migrate meta has nothing to reach. Withholding the branding namespace under a walled posture moves no stored row either. The one operator-facing consequence there, that OS_BRANDING_WORKSPACE_NAME stops naming sign-in mail and the OS_APP_NAME variable takes its place, is a deployment environment setting: no consumer code and no metadata is rewritten, so the ledger has nothing to carry and objectstack migrate meta nothing to reach. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a settings key (not already-registered); and the change is the value of an exported manifest constant and the plugin's registration behaviour, not an interface or a type alone (not runtime-interface-only or type-surface-only). --> + + 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which runtime values two settings doors accept: a value saved through `PUT /api/settings/:namespace` or `setMany`, and a deployment's `OS_*` environment override. Settings values are runtime state in `sys_setting` (ADR-0007) and environment values belong to the deployment, and no metadata conversion reaches either; a row already stored is not rewritten and is still served. The remedy is to supply a well-formed value, never a rewrite of a metadata source. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. +- 42b60b9: fix(service-settings): Setup → Company → Legal name help no longer points to an organization name under Branding + + Clause-②: no + + Metadata-protocol: none + + The `legal_name` help in the Company settings page (the manifest description and the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles) said the legal name "may differ from the organization name set under Branding". Branding's workspace name is the product name, not an organization name, and the Branding page is not offered at all under the `group` and `isolated` postures, where Company is. The help now reads "Registered legal name of the organization, as it appears on contracts and invoices." in every locale. Copy only; no key, value or behaviour changes. +- 279fdcf: fix(service-settings)!: a tenant- or user-scope setting stored with no organization no longer answers every organization's read, and a caller with no organization reads and writes as the one organization the deployment derives + + Clause-②: yes (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) Which stored `sys_setting` row answers a settings read changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, error code or route is removed or renamed: `bindEngine` gains one optional input, and the plugin supplies it. The remedy for the rows this stops reading is the existing organization-ownership ceremony, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** read narrowing (ADR-0131 D1 "nothing reads a NULL tenant value as an owner", and D8 "no layer has a NULL arm"). It changes which stored row a settings read takes, not what a metadata author can write, so it ships as `patch` with this banner. + + - FROM: a `tenant`- or `user`-scope `sys_setting` row written with no organization was every organization's fallback. A caller that named an organization read its own row, or failing that the organization-less one. A caller that named none read only organization-less rows behind a wall (`group` / `isolated`). Under `single` it read every row, an organization's first. Under `single` such a caller also wrote with no organization, so its write's identity probe missed the row the engine stamped with the Default Organization. The second save of a key then inserted a second row beside the first. On a SQL store the identity index refuses that insert and the save fails. On a store with no such index the save was lost: every reader kept answering the first value. + - TO: a caller reads its organization's rows and no others. For a caller that names no organization, the deployment names one (ADR-0131 D9, the predicate the engine applies to a system write): + - Under `single` with one organization, the caller reads and writes as that organization. This covers the process-wide readers: plugin-auth's email brand name and auth locale, and the anonymous `runtime/config` branding read. + - Behind a wall, or under `single` with no organization or several, the caller has no tenant or user rung. Its read answers from the global rung or the default, and its tenant-scope write is refused (`SETTINGS_VALIDATION`, `constraint: { scope: 'tenant' }`). + - A composition that registers no organization object (a lean embedding, kept by ruling) still stores and reads its rows with no organization. + + **Who is affected.** Only a store holding a tenant- or user-scope `sys_setting` row with a NULL `organization_id`. A fresh install holds none, nor does an 18.0.0-born store written through `os serve`: the measured 18.0.0 fixture holds two `sys_setting` rows, both owned. A v17 store holds none after `os migrate organization-ownership` under `single`, which gives such rows to the Default Organization. Under a walled posture the ceremony leaves such a row NULL and reports it, and from this release that row stops answering any organization. + + **The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each value again from inside the organization it belongs to (Setup → the settings page, or `PUT /api/settings/:namespace` with that organization active). A value meant for every organization belongs on a key declared at scope `global`. +- 9d60b42: fix(service-settings)!: a host that binds its own engine and states no organization source reads and writes as the organization ADR-0131 D9 derives through that engine, not as no organization + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) Which organization a self-binding host's settings reads and writes take changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, option, error code or route is added, removed or renamed: `SettingsServiceOptions` and the `bindEngine` extras are unchanged, and `SettingsServicePlugin` already states its source. The remedy for a host this narrows is one line at its bind site, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **BREAKING** read narrowing (ADR-0131 D8/D9, follow-up to the organization-less rung fix). It changes which organization a host's settings reads take, not what a metadata author can write, so it ships as `patch` with this banner. + + - FROM: a host that bound its own engine (`new SettingsService({ engine })` or `bindEngine(engine)`) and stated no `organization` source read and wrote, for a caller that names no organization, as a composition with no organization object: its rows with no organization answered, and its writes stored none. + - TO: such a host's service asks D9 itself, through the engine it holds (`resolveGuestOrganization`, the call the plugin's source makes). Under `single` with one organization it reads and writes as that organization; at none or several it has no tenant or user rung. An engine that exposes no `registry` cannot say the organization object is absent, so D9 asks `sys_organization`. Over a store with no such object, a tenant-rung read or write by a caller naming none is then refused as organization-less (reads answer from the global rung or the default, tenant writes are refused with `SETTINGS_VALIDATION`), or raises `AuthzStoreUnavailableError` naming `sys_organization` where the engine does not know that object. No engine at all (the in-memory store), a stated source, an engine whose `registry.getObject` answers, and `SettingsServicePlugin` (`os serve`, `os dev`) are unchanged. + + **Who is affected.** Only a host that binds its own engine and states no source: a control-plane mock or an embedded runtime. On a composition with an organization object it now reads and writes the derived organization's rows, and its rows with no organization stop answering. On one without, it meets the refusal above. A deployment booted through `SettingsServicePlugin` is not affected. + + **The fix.** State the composition at the bind site: `service.bindEngine(engine, audit, { organization: async () => ({ kind: 'no-organization-object' }) })` (a host that hands its engine to the constructor binds it this way instead), or hand over an engine whose `registry.getObject` can answer. +- b3034ee: refactor(core, plugin-auth, plugin-security, service-settings): one definition each for the `admin_full_access` anchor rule and the organization-reach rule, exported from core security + + Clause-②: yes (widening) + Metadata-protocol: none + + Two security rules were each spelled more than once. A change to one copy could leave the other behind. Each now has one definition in `@objectstack/core`'s security module, and every reader calls it. No behaviour change: every reader answers exactly as before. + + - **The anchor rule (ADR-0131 D5).** New export: `organizationAnchorsPlatformStanding(grantOrganizationId, { walled, defaultOrganizationId })`, with the type `PlatformStandingAnchorFacts`. It decides which `admin_full_access` grant anchors platform-admin standing: + - behind a wall, no grant does; + - under `single`, the grant owned by the Default Organization does; + - an organization-less grant never does, and neither does any grant when no Default Organization can be named. + + Three readers call it: + - the authorization resolver passes the memoised Default Organization; + - the last-administrator guard in `@objectstack/plugin-auth` passes the Default Organization as the pending write would leave it; + - the first-user promotion's holder scan in `@objectstack/plugin-security` passes the Default Organization it promotes into, and keeps its own system-user exclusion. + + Each reader keeps its own normalizer for a grant row's organization. + - **The organization-reach rule (ADR-0131 D8/D9).** New exports: + - `resolveOrganizationReach({ callerOrganizationId, wallStands, deriveOrganization })`; + - `organizationReachAdmits(reach, rowOrganizationId)`; + - `organizationReachRowOwner(reach)`; + - `organizationIdOf(value)`; + - `UNOWNED_ORGANIZATION_REACH`; + - the types `OrganizationReach` and `OrganizationReachFacts`. + + The rule decides whose `sys_setting` rows a caller may draw on: + - the caller's own organization; + - for a caller naming none where no wall stands, the organization D9 derives; + - nothing behind a wall; + - rows with no organization, only in a composition that registers no organization object. + + `SettingsService` and the resolver's direct localization fallback both call it. Each still supplies its own facts: + - the service: the posture in force (the `tenancy` service), with a failed read propagated to its caller; + - the fallback: the requested posture (`OS_TENANCY_POSTURE`), with a failed read degraded to the built-in defaults. + + These two differences are kept by design: they make the fallback, which runs only when the service has failed, the stricter reader. + + No configuration, metadata or HTTP behaviour changes. Code that does not import the new exports needs no action. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..f7411cefc62 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,113 @@ # @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 [448374d] +- 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 [abc25a3] +- Updated dependencies [644e4b2] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [688052e] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [c6bdcac] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [829cb77] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [431327b] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..9ba34d680f0 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,112 @@ # @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. +- 0f1eeee: fix(plugin-audit,service-storage): a delegated (on-behalf-of) caller reads the parent-record gates through the ReadableIds leaf, no longer through the bounded 2000-row probe (ADR-0139 D5.1, as amended) + + Clause-②: no + Metadata-protocol: none + + The activity, ledger and comment-thread read gates (`@objectstack/plugin-audit`) and the attachment read gate (`@objectstack/service-storage`) routed every read by a delegated caller to the whole-read probe: a SYSTEM pre-scan of at most 2000 rows, then one caller-scoped read per parent object. Past 2000 candidate rows, a delegated caller was served a truncated list (fail-closed, never a leak) with a matching `total`. + + Since the engine composes a `ReadableIds` leaf for a delegated caller from the delegator intersection (ADR-0090 D10, #25009), the gates now write such a caller the same leaves as any caller. It is served every row its delegator intersection admits, with a true `total`. This is the same set the probe's own readability answer admits, without the window. ADR-0139 D5's two remaining classes are unchanged: a parent narrowed by an object-scoped read middleware, and a parent bound to another datasource. Both still take the bounded probe, read with the caller's delegated context. The gates no longer read the delegation link themselves, and the `delegated-caller` value is gone from the gates' internal whole-read probe reasons. Neither is exported. +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [5d2c5a1] +- Updated dependencies [066dabc] +- Updated dependencies [4b8f597] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [97aa34e] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..cf9361023cc 100644 --- a/packages/skills/CHANGELOG.md +++ b/packages/skills/CHANGELOG.md @@ -1,5 +1,28 @@ # @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. +- 7f5b19d: docs(spec, skills): the `titleFormat` description and the objectstack-data skill no longer steer a composite record title that must be queried into a formula field + + Clause-②: no + Metadata-protocol: none + + The `titleFormat` description (and its generated copies on the object, metadata and migration reference pages) and the `objectstack-data` skill's `nameField` and `titleFormat` rows told an author to migrate a composite record title to a `formula` field with `returnType: 'text'` designated as `nameField`. A formula value is computed on read and stored nowhere, so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell against it. Both now say what the record-title lint and ADR-0079 D3 already say: a composite title that import, sort or search must match is a stored computed field, a non-`formula` field that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`; a text formula designated as `nameField` stays legal for a title shown for display only. + + The same skill's "Computed column" bullet (`rules/relationships.md`) said a stored field's `expression` is a client compute hint and the sent value is stored verbatim. The engine computes the expression from the row the write stores and stores the result, so a client-sent value never stands; the bullet now says so. + + No key, type, default or accepted value changes; only the text an author reads changes. + ## 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..d0ef1f07a3c 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,785 @@ # @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. + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. --> + + **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<DroppedFieldsEvent['reason'], …>` 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) + + <!-- adr-0087: registered add-record-mode-form-view-removed, add-record-enabled-defaulted, add-record-mode-form-view-retired, add-record-enabled-required --> + + **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: <role>`) 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.<node_id>.label`, the step label of an `approval` node + + Clause-②: yes (widening) + + - **Before.** `TranslationDataSchema.flows.<flow>` carried `label`, `successMessage`, `errorMessage`, `screens.<node_id>` (screen nodes) and `refusals.<node_id>` (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.<node_id>.title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen. + - **Now.** `flows.<flow>.approvals.<node_id>.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.<node_id>.title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals.<node_id>.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) + + <!-- adr-0087: registered sys-notification-subscription-retired --> + + **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`. +- 2069b81: feat(spec)!: a `user` field whose `reference` names an object other than `sys_user` is refused at parse, and every `user` field resolves to `sys_user` + + Clause-②: no (narrowing) + Metadata-protocol: narrowed + + <!-- adr-0087: registered user-field-foreign-reference-refused --> + + **BREAKING** (the accept set of `FieldSchema` narrows), shipped as `minor` under the repo's launch-window convention for breaking changes. + + - **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, and `referenceTargetOf` resolved it to `probe_account`. Readers that follow the target (`$expand`, imports, seed loading, field paths) treated the field as a link to `probe_account`, while readers that branch on the type (the person picker, a `current_user` default, a sharing rule over the users a field holds) treated its values as users. + - **FROM:** a `user` field with `reference` naming any object but `sys_user` parsed, and resolved to the named object. + - **TO:** `FieldSchema` refuses it with an issue at `reference` that names the replacement, so `ObjectSchema`, `ObjectExtensionSchema`, `defineStack`, `os validate` and the metadata save door refuse it too. `referenceTargetOf` answers `sys_user` for every `user` field, whatever `reference` says, so a stored body saved before this release resolves to `sys_user` as well. `Field.user()`'s config types `reference` as `'sys_user'` and writes `sys_user` after the config spread. + - **Unchanged:** a `user` field without `reference`, with `reference: 'sys_user'` or with a blank `reference`, and `Field.user()`'s output for every typed call. + - **Fix:** change such a field to a `lookup` on the object it names, `{ type: 'lookup', reference: 'TARGET' }` (`Field.lookup('TARGET', …)`), and choose its `deleteBehavior` on purpose: a `lookup` defaults to `'set_null'` when the target record is deleted, which a `user` field never did. A `current_user` default is not legal on a `lookup` to an object other than `sys_user`. If a person was meant, drop `reference`. + - **Why no automatic rewrite.** No ADR-0087 conversion can carry the change losslessly: a `lookup` joins the referential delete walk and `deleteBehavior` has no value that means "leave it". `objectstack migrate meta` reports the semantic entry `user-field-foreign-reference-refused` instead. + + **Supersedes the `user-field-foreign-reference` warning entry in this release.** That entry announced a `@objectstack/lint` advisory `user-field-foreign-reference`, the `USER_FIELD_FOREIGN_REFERENCE` export and its `os explain` entry, and said such a field would still parse. All three are withdrawn before release and never ship: a foreign `reference` on a `user` field is now refused by `FieldSchema` instead, so there is nothing left for the advisory to warn about. + + - **For `@objectstack/lint`:** no `user-field-foreign-reference` rule, no `USER_FIELD_FOREIGN_REFERENCE` export and no `RULE_EXPLANATIONS` entry for it. `OBJECT_REFERENCE_UNKNOWN` and every other export are unchanged. The refusal arrives as a `FieldSchema` issue at `reference`, not as a lint advisory. +- 30d21f6: feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label` + + Clause-②: yes (widening) + + - **Before.** `pages.<page>.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.<page>.components.<tabsId>.items.<value>.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.<object>._views.<view>.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.<view>` 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.<object>._views.<view>.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.<key>` 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.<view>.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. + + <!-- adr-0087: not-required (no-migration-prescription) no authorable key, export or stored shape is removed or renamed; the read doors serve less, and a stored webhook needs no migration --> +- 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) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which `defineStack` input is accepted: a stack that declares a webhook and lacks one of two capability tokens is refused at load. The remedy is a capability declaration the author makes in their own `requires`, never a rewrite of a key or a stored row, and no token is implied or added for them, deliberately (ruling A on #24910). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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 + + <!-- adr-0087: registered field-select-shorthand-keeps-written-value --> + + **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. + + <!-- adr-0087: registered turso-config-local-encryption-key-refused --> +- 2354058: feat(spec,driver-turso)!: turso `config.encryptionKey` is removed — it never encrypted anything, in any mode, and is now refused everywhere it is written instead of accepted on a remote datasource + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered turso-config-encryption-key-removed, turso-config-encryption-key-retired --> + + `encryptionKey` was declared as an AES-256 key for the database file. Nothing ever applied it. A local or embedded-replica file is read and written through better-sqlite3, which has no encryption codec, so #25020 already refused the key there. A remote database was never handed the key either: its storage encryption is configured on the server. That left a key that parsed on a remote datasource and did nothing. It was also the one credential-shaped key a datasource could still write in plain text (`STILL_WRITABLE_CREDENTIAL_KEYS`). Under ADR-0049 (enforce-or-remove) it is removed. + + **BREAKING** accept-set narrowing on a published schema, a published constructor and an exported type. It ships as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). + + - **`@objectstack/spec`**: `TursoConfigSchema.encryptionKey` is a `retiredKey()` tombstone. Authoring it is a `tsc` error and a parse error at `config.encryptionKey`, in every mode and with any value, the empty string included. The error carries the removal prescription. It is the only issue reported on that config, whatever else is wrong. The misspellings `encryption_key` and `encryptionkey` get the same prescription. The #25020 local and replica refusal arms are gone, since the tombstone subsumes them. + - **`@objectstack/driver-turso`**: the published mirror `TursoConfigSchema` refuses the key the same way, as `z.never()`. `TursoDriverConfig.encryptionKey` is typed `never`. `new TursoDriver()` and `createTursoDriver()` refuse a defined `encryptionKey` with `VALIDATION_ERROR` / 400 in every mode, before anything else is checked, in the schema's words. + - **`@objectstack/service-datasource`**: the exported `TursoDriverConfigInput.encryptionKey` is typed `never`. The shared loader still forwards a declared value, so a legacy stored row reaches the constructor's refusal instead of being dropped in silence. + + ### Migration: FROM → TO + + | You wrote | Write instead | + | --- | --- | + | `url: 'libsql://my-db.turso.io', encryptionKey: '…'` (also `https://`, `ws(s)://`) | delete `encryptionKey`. Storage encryption for a remote database is configured on the Turso server | + | `encryptionKey: ''` (or any unset value) | delete `encryptionKey` | + | `url: 'file:./data/app.db', encryptionKey: '…'` (also `:memory:`, or a replica beside `syncUrl`) | delete `encryptionKey` (already refused since #25020). Put the file on an encrypted disk or volume if it must be encrypted at rest | + + The fix in one line: delete `encryptionKey` from every turso datasource. + + ### The ADR-0087 disposition + + - **D2 `turso-config-encryption-key-removed`** deletes the key from a turso datasource's `config` when its value is unset (any non-string, or `''`) or when it sits beside a remote url (`libsql://`, `https://`, `http://`, `ws://`, `wss://`, in any case). It emits one notice per key. Nothing is lost: the driver never read the value. + - A non-empty key beside a local url is **not** deleted. Removing it would let a datasource whose operator believed the file was encrypted boot in silence. It keeps the #25020 posture instead: the row fails at driver construction with the prescription, and the author deletes the key by hand. + - The conversion is retired from the load path. A live author is refused at parse and taught. Stored rows (the datasource loaders), built artifacts inside their declared-floor window and `os migrate meta --from 17` replay it. + - `RETIRED_KEYS_BY_MAJOR[18]` gains `data/TursoConfig:encryptionKey`. The D3 entry is `turso-config-encryption-key-retired`. + + ### Stored rows stay redacted + + A tombstone is excluded from the schema-derived credential list, by its `[REMOVED]` prefix. Measured: after the retirement, no existing path would have withheld a stored `encryptionKey` on read. The derived turso list (`authToken`) is non-empty, so the canonical fallback never applies. `STILL_WRITABLE_CREDENTIAL_KEYS` is therefore renamed `RETIRED_CREDENTIAL_KEYS` and keeps `turso: ['encryptionKey']`. A legacy row's value is still withheld and still named in `redactedConfigKeys`, on the admin door and the metadata door alike. + + ### Blast radius + + Measured on this tree: no example, template, CLI scaffold, published skill, host default or environment variable authors the key. A tree-scoped absence pin now holds that. Whether any out-of-repo deployment declares it is not measured, and is not claimed to be zero. +- 02ad31a: feat(spec)!: a `controlled_by_parent` object whose one required `master_detail` is marked `readonly: true` or `system: true` is refused at parse + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered cbp-master-relation-tiers-retired) registered before this change, amended in this diff: the parse's one-master entry now carries this narrowing, and the stored-row listing reports it under that id --> + + **BREAKING** (a declaration the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage. + + ADR-0055 §1: a `controlled_by_parent` object derives its record access from exactly one required `master_detail` field. That one master must be a reference every write of a detail supplies, and a field declared `readonly: true` or `system: true` is not one an author's write supplies, so it cannot be that master. `os lint` already reported it at `error` under `relationship/master-detail-required`, but `os lint` is not a publishing door: `os validate`, `os build`, `ObjectSchema.create()`, `defineStack` and the metadata save door accepted it. + + - **FROM:** `sharingModel: 'controlled_by_parent'` with one required `master_detail` declared `readonly: true` and/or `system: true` parses, builds and saves. + - **TO:** the parse refuses it at `fields`, naming the object, the field and each flag (the message opens with "Object 'OBJECT' declares sharingModel 'controlled_by_parent' with its one required master_detail, FIELD, marked readonly: true", with each name and flag in a code span). The save door answers `422 INVALID_METADATA` with that issue and stores nothing. `ObjectSchema.create()` refuses it too, because the parse it ends in is the same rule. An objectExtensions entry that leaves the merged master flagged is refused at the extension, as before for the other shapes. + - **The fix:** remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`). + - **Unchanged:** the runtime. The flags do not change which field the runtime resolves as the master. A flag on a second, non-required `master_detail` beside the master is still reported by `os lint` alone. + + Data at rest still loads. A stored object in this shape registers under its `[metadata_spec_invalid]` diagnostic, and `os migrate meta --stored` lists it as a TODO under `cbp-master-relation-tiers-retired`. No conversion rewrites it: dropping the flag changes who may write the reference, which is the owner's decision. + + `@objectstack/spec/data`: `controlledByParentMasterRefusal` returns the new shape `master-not-writable` for this object, so a producer that asks it before writing `controlled_by_parent` gets the parse's verdict. `ControlledByParentMasterShape` gains that member. `isControlledByParentMasterField` is unchanged: a flagged required `master_detail` still counts as the master, so beside a second required one the object is still refused as having two. + + `@objectstack/lint`: the `relationship/master-detail-required` explanation (`os explain`) now says that the parse refuses every shape that leaves the object without exactly one writable required master, and that a shape on a second, non-required `master_detail` is this rule's report alone. +- 14e7239: feat(spec)!: the docs-viewer keys are retired — `ApiDocumentationConfig.ui` (with `ApiTestingUiConfigSchema` / `ApiTestingUiType`) and the REST API plugin config's `openApi.uiPath` / `openApi.uiFramework` are refused with a prescription naming the OpenAPI document (ADR-0049) + + Clause-②: no (narrowing) + Metadata-protocol: narrowed + + <!-- adr-0087: registered api-docs-viewer-keys-retired --> + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. + + Three keys declared an HTML API documentation viewer, and nothing ever read any of them: `ApiDocumentationConfig.ui` (a viewer type such as `swagger-ui`, `redoc` or `scalar`, served at `ui.path`, default `/api-docs`), and on the REST API plugin config `openApi.uiPath` (default `/api/docs`) with its viewer selector `openApi.uiFramework` (default `swagger-ui`). Since the docs viewer page was taken offline, the API origin serves no HTML viewer at all, only the machine-readable OpenAPI document, so none of the three could be honoured. They are retired under ADR-0049 enforce-or-remove, with no staged window. + + **What is refused:** each key is a `retiredKey()` tombstone. Authoring it is a `tsc` error at the site, and a parse error at the key's path whose message is the prescription, for every former value including the old defaults. The value schemas `ApiTestingUiConfigSchema` and `ApiTestingUiType`, with the `ApiTestingUiConfig`, `ApiTestingUiConfigParsed` and `ApiTestingUiType` types, are no longer exported: they had no consumer once `ui` was gone. + + **What stays accepted:** every other key of `ApiDocumentationConfigSchema`, `OpenApiGenerationConfigSchema` and `RestApiPluginConfigSchema`, byte-identically, defaults included. The mounted REST surface is unchanged: none of the three keys ever reached it. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `ui: { type: 'swagger-ui', … }` on an `ApiDocumentationConfig` | nothing: delete the key | + | `openApi: { uiPath: '/api/docs' }` on a REST API plugin config | nothing: delete the key | + | `openApi: { uiFramework: 'redoc' }` on a REST API plugin config | nothing: delete the key | + | `import { ApiTestingUiConfigSchema, ApiTestingUiType } from '@objectstack/spec/api'` | nothing: no replacement exists | + + The one-line fix: delete `ui`, `uiPath` and `uiFramework`, and point any viewer or client generator you run yourself at the OpenAPI document, `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both), mounted while `api.enableOpenApi` is on. + + ## Who is affected, measured + + - **objectstack** at `b93c750c7`: zero readers and zero authorings outside `packages/spec`. The only identifier hits were a local `uiPath` variable for the unrelated `/ui/view` route in `packages/rest`; nothing in the tree parses the three schemas outside their own unit tests. A tree-scoped absence pin now holds that zero. + - **objectui** at the console pin `.objectui-sha` `56cf4d8e89`: zero hits for any of the keys or exported names, so the console build at the pin is unaffected. + - **Deployed configuration** was not measured. + + ## The ADR-0087 disposition + + - `RETIRED_KEYS_BY_MAJOR[18]` gains `api/ApiDocumentationConfig:ui`, `api/OpenApiGenerationConfig:uiPath` and `api/OpenApiGenerationConfig:uiFramework`; `RETIRED_DEFS_BY_MAJOR[18]` gains `api/ApiTestingUiConfig` and `api/ApiTestingUiType`. + - The D3 semantic entry `api-docs-viewer-keys-retired` and its step-18 rationale fragment carry the prescription to `os migrate meta`, the upgrade guide and `spec-changes.json`. + - No D2 conversion: neither config is a stack collection member or a stored row, so no conversion seam would ever see one. `uiPath` and `uiFramework` carried Zod defaults, but nothing parses the REST API plugin config into a stored or built artifact, so no emitted default needs tolerating. + + The retirement kit: + + - the three tombstones and their retirement records (`packages/spec/src/api/documentation.zod.ts`, `packages/spec/src/api/plugin-rest-api.zod.ts`); the two value defs deleted; `REST_API_PLUGIN.md`'s example and feature list no longer name a viewer + - pin test `docs-viewer-keys-retirement.test.ts`: `code`, `path` and the prescription at each door (each schema and `RestApiPluginConfigSchema.openApi`), the `tsc` channel, the live controls, zero holders for the retired names, the ADR-0087 registration, and a tree-scoped absence walk + - generated artifacts follow the schema: `authorable-surface/`, `authorable-defaults/`, `json-schema.manifest/`, `api-surface/`, `export-origins/`, `declaration-map/`, the strictness-ledger counts and the reference pages for `api/documentation` and `api/plugin-rest-api` +- fe0ec2b: fix(spec)!: an object's `external` binding on a managed datasource is refused at the parse and by `defineStack`, as ADR-0015 §4.2 declares + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered object-external-binding-managed-datasource-refused --> + + **BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'`, and declares the rule "enforced at metadata-load time". No load door enforced it: `defineStack`, `os validate` and the object save door (`PUT /api/v1/meta/object/NAME`) all accepted an `external` binding on a managed datasource, the primary `default` included. The runtime then disagreed with itself about such an object: its schema sync treated it as federated and never created its table, while the boot validation skipped it and the write gate exempted it because its datasource is managed. + + - **FROM:** an object declaring `external` was accepted whatever its datasource: `datasource` omitted or `'default'`, or a datasource declared with `schemaMode: 'managed'` or with no `schemaMode`. + - **TO:** the rule is judged where each half is visible. + - An `external` binding whose object `datasource` is `'default'` (also the value when none is written) is refused by `ObjectSchema`'s parse at `external`: `ObjectSchema.create` throws at module load, `defineStack` and `os validate` refuse with `STACK_SCHEMA_INVALID` (422), and the object save door answers `422 INVALID_METADATA` and stores nothing. The platform's primary datasource is managed by construction. + - An `external` binding on a datasource the same stack declares with `schemaMode: 'managed'`, or with no `schemaMode`, is refused by `defineStack` (and so `os validate` and `os build`) with `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object. + - A package `defaultDatasource` or a `datasourceMapping` rule does not make an object federated: they route its queries, while the boot validation and the write gate read the object's own `datasource`. + - **Unchanged:** a binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted. A binding on a datasource the stack does not declare (registered by the host or another package, or stored at runtime) gets no verdict at these doors, because they cannot see its `schemaMode`. `defineStack(config, { strict: false })` skips the stack-level judgement by contract. A stored object row in the refused shape still registers, under a `[metadata_spec_invalid]` diagnostic, and its next save is refused; `os migrate meta --stored` lists each such row under `object-external-binding-managed-datasource-refused`. + - **The fix for an author:** set the object's `datasource` to the datasource that holds the remote table and declare that datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings, for example `datasource: 'warehouse'` beside `defineDatasource({ name: 'warehouse', schemaMode: 'external', external: { … } })`. An object whose rows ObjectStack should own drops `external` instead. +- bda53bb: fix(metadata-protocol)!: the metadata save doors and `composeStacks` refuse an object's `external` binding on a named managed datasource, from either side of the pair + + Clause-②: yes (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused) registered by #25168 before this change, amended in this diff: its surface, replacement and reason now name composeStacks and the two metadata save doors --> + + **BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes. + + ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'` (an absent `schemaMode` is `'managed'` by the spec default). #25168 enforced the halves a parse and a single stack can see. A binding on a datasource stored or registered at runtime, or declared by another input of a composition, still got no verdict: the save door stored an `external` object on a managed datasource, stored a datasource re-saved as managed under objects bound to it with `external`, and `composeStacks` accepted the pair split across two inputs. The runtime then disagrees with itself about the object: its schema sync skips the table as federated while the boot validation and the write gate judge it as managed. + + - **FROM:** `PUT /api/v1/meta/object/NAME` stored an object with `external` and a `datasource` naming a stored or registered datasource declared `schemaMode: 'managed'` or with no `schemaMode`, in draft and publish mode and under `force`. `PUT /api/v1/meta/datasource/NAME` stored a datasource as managed (or with no `schemaMode`) while stored or registered objects bound it with `external`. `composeStacks` accepted an `external` object of one input bound to a managed datasource another input declares. + - **TO:** + - The object save door refuses the object with `422 INVALID_METADATA`, the finding at `external`, nothing stored. The datasource it judges against is the one the door's own by-name read serves: the environment's stored row, else the definition the code layer registers. + - The datasource save door refuses the datasource with `422 INVALID_METADATA`, one finding per bound object at `schemaMode`, the stored row unchanged. The objects it judges are the environment's active object rows and the registered objects no stored row overlays. An object's draft binds nothing until it is published. + - Both are judged in draft and publish mode, and again when a draft is published (`publishMetaItem` and each item of a package publish), against the other half as it stands then. `force` does not skip them. + - `composeStacks` refuses the cross-input pair with `STACK_CROSS_REFERENCE_INVALID` (422), in either input order and under every `manifest` strategy, `preserve` included. An input built with `strict: false` that holds the pair itself is refused here too, as with the artifact pass's other rules. + - Every finding is the spec predicate's, so it reads as the parse's and `defineStack`'s do: "Object 'OBJECT' declares `external` on datasource 'NAME', whose `schemaMode` is 'managed' …". + - **Unchanged:** the rule itself. A binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted on every door. A datasource no layer holds gets no verdict: the absence of a definition is no evidence about its `schemaMode`. The datasource-admin service's update (`PATCH /api/v1/datasources/NAME`) does not pass through the metadata save door and is not judged by this change. Rows already stored in the refused shape still load as before; a save of either side that leaves the pair inconsistent is refused, so the next object save waits until the datasource is external or the object drops `external`. + - **The fix for an author:** keep the pair consistent, in this order. To federate, save the datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings first, then bind objects to it. To make a datasource managed, remove `external` from the objects that bind it first, then save the datasource. + + This narrows the **Unchanged** bullet of the #25168 changeset, which said a binding on a datasource "registered by the host or another package, or stored at runtime" gets no verdict at the load doors: the metadata save doors now judge it, and `composeStacks` judges one another input declares. + + `@objectstack/spec/data`: exports `externalBindingDatasourceRefusal` and its result type `ExternalBindingDatasourceRefusal`, the one predicate behind the rule, so a runtime door resolves the datasource it can see and asks it rather than copying the judgement. Its behaviour is unchanged. +- b964ea1: feat(spec,core,objectql): one package entry of a release artifact may state its own `source`, and the registry records it + + Clause-②: yes + Metadata-protocol: added + + `ArtifactPackageSchema` (one `packages[]` entry of an assembled release artifact) gains an optional `source: 'code' | 'marketplace'` beside `manifest`. The values are the new `ArtifactPackageSourceSchema`, the subset of `InstalledPackageSourceSchema` a load path can register; `authored` and any other value are refused at the entry gate as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). + + The `manifest` service registers each package with the `source` its entry states, so a merged artifact that carries marketplace installs beside the deployment's own code lists them as `marketplace` on `GET /api/v1/packages`. An entry that states nothing takes the registering door's statement as before (`register(artifact, { source })`, `code` when absent), so existing artifacts register unchanged. An entry's own statement wins over the door's. + + `@objectstack/core` adds `resolveArtifactPackageEntries(artifact)`, which returns each package to register as `{ manifest, source? }` in dependency order. `resolveArtifactPackageOrder` is unchanged and still returns the bodies alone. +- f5b6b3f: fix(lint)!: a page or view `visibleWhen` that hands `has()` an argument other than a field selection is refused at publish + + Clause-②: no (narrowing) + + Metadata-protocol: narrowed + + <!-- adr-0087: registered visibility-predicate-has-argument-refused --> + + **BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes. + + CEL defines `has()` in one form, `has(e.f)`, with `f` a field name after a dot. The engine refuses any other argument as `has() invalid argument` on every evaluation, whatever the record holds. Every other predicate surface already refused it through `validateExpression`. The page and view `visibleWhen` door did not, so `has(status)` and `has(record.items[0])` published clean, and the console, which renders a faulting predicate as visible, drew the guarded element for every user. + + - **FROM:** a `visibleWhen` on a page component, a form view section or a section field could hand `has()` a name alone (`has(status)`), an index as the last step (`has(record.items[0])`), or any other argument that is not a field selection (`has(record.?a)`, `has(upper(record.x))`, a literal, an operator), and `os validate`, `os build`, `os lint` and the page and view save doors accepted it. + - **TO:** each is refused as `visibility-predicate-has-argument`, an error, at those doors (the save doors answer `422 INVALID_METADATA`). The finding quotes the argument and the engine's wording, and its hint names the spelling to write. `os explain visibility-predicate-has-argument` says why. + - **Unchanged:** `has(e.f)` for any `e`, including a selection over an indexed or called receiver (`has(record.items[0].name)`); every other predicate, and nothing about the types that flow through one is judged. A `has(status) && status == 'x'` keeps its `visibility-bare-identifier` finding on the unguarded `status`, beside the new one. A stored page or view row in the refused shape still loads and is served; its next save is refused, and `os migrate meta --stored` lists each such predicate under `visibility-predicate-has-argument-refused`. + - **The fix for an author:** write `has(record.status)` or `'status' in record` for a field (`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether a list has a first element (`'k' in e` for a bracketed string key). +- f4112a7: feat(spec): declare the console flag `features.cloudManaged` — a `PUBLIC_AUTH_FEATURES` entry with a new hide-when-on gate semantics, and `AuthFeaturesConfigSchema` declares it + + Clause-②: yes (widening) + Metadata-protocol: added + + The managed (cloud-hosted, SSO-only) mode gets its console flag. People in a managed environment arrive through the organisation's identity provider, so the console hides the doors that would add them any other way. + + - **`PublicAuthFeatureSemantics` gains `'hide-when-on'`.** The two existing semantics gate an input on a capability being present (`opt-in` lowers to `features.X == true`, `default-on` to `features.X != false`). The new one gates an input on a mode being absent: `featureGatePredicate` lowers it to `features.X != true`, so the input is hidden when the flag is on and visible when it is off or not served at all. + - **`PUBLIC_AUTH_FEATURES.cloudManaged`** (`surface: 'crud'`, `semantics: 'hide-when-on'`) books six gated inputs: the four `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`, `sys_organization`), `sys_user.actions.create_user` and `sys_user.actions.set_user_password`. `PUBLIC_AUTH_FEATURE_NAMES` lists it, so `requiresFeature: 'cloudManaged'` is now accepted on an action or param and lowers to `visible: features.cloudManaged != true`. + - **`AuthFeaturesConfigSchema.cloudManaged`** is an optional boolean on the `GET /api/v1/auth/config` response's `features`. Absent means not managed. + + What to do: nothing. Every key and value accepted before is accepted unchanged, and every existing flag lowers to the same predicate as before. +- 303aef0: fix(spec)!: a flow `get_record` node's `limit` is declared a positive integer, so a fraction, `0` or a negative is refused at every door that parses a flow + + Clause-②: no (narrowing) + Metadata-protocol: narrowed + + <!-- adr-0087: not-required (already-registered flow-builtin-node-config-values-refused) registered before this change, amended in this diff: the entry's surface, replacement and reason now name a get_record limit that is not a positive integer, and the stored-row listing reports such a node under that id --> + + **BREAKING** (a value the parse accepted is now refused), shipped as `minor` under the 18.x convention for breaking changes, with no retirement stage. + + The `get_record` executor reads around the Data API's page-window normalizer: with `limit` above 1 it calls `data.find` with that LIMIT as written, and otherwise it calls `findOne`. The spec declared `limit` as any number, so a flow could declare a window the runtime did not honour as written. + + - **FROM:** `GetRecordConfigSchema.limit` was `z.number()`. `limit: 2.5` parsed and reached the driver as `LIMIT 2.5`; `limit: 0`, `limit: -1` and `limit: 0.5` parsed and silently read one record through `findOne`. + - **TO:** `limit` is `z.number().int().positive()`. Every door that parses a flow refuses any other number at `nodes.N.config.limit` with the code `node-config-refused-by-contract`: `FlowSchema.parse`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `objectstack validate` and `objectstack compile`, the metadata save door, `AutomationEngine.registerFlow`, and an artifact's parse. The generated JSON Schema declares `type: integer` with an exclusive minimum of 0. + - **Fix:** write the count as a whole number of at least 1. `limit: 1` (or no `limit`) reads one record as `record`; a larger whole number reads a `records` list of at most that many. + - **Unchanged:** every positive whole `limit`, and a node with no `limit`. A string `limit` (`'10'`, `'{page.size}'`) was already refused as the wrong type. + - **Not the Data API's page window.** `QueryPageWindowSchema` admits `0`, which asks a list door for the total alone. A `get_record` node always reads at least one row, so its bound is its own. + + Data at rest: a flow already stored with such a `limit` is refused whole when it registers. At boot it is skipped with a `warn` naming it and its trigger is not armed, while the flows beside it register. `objectstack migrate meta` reports it under `flow-builtin-node-config-values-refused`. No conversion rewrites it, because only the author knows the count they meant. + + No producer writes such a value: every `get_record` `limit` in this repository's examples and tests, and in HotCRM, is a positive whole number. +- 7c68b10: feat(spec): a dataset dimension may name its null bucket with `nullLabel` + + Clause-②: yes (widening) + Metadata-protocol: added + + `DatasetDimensionSchema` gains an optional `nullLabel` (`I18nLabel`: a plain string or an inline locale map such as `{ en: 'System', 'zh-CN': '系统' }`). It names the bucket of rows whose value for that dimension is null, for example `'System'` on a `user_id` axis where the rows no user wrote carry no user. It is display copy only and changes no grouping. + + - **What is accepted now.** `dimensions[].nullLabel` on a dataset, in either label form. Every dimension that parsed before parses unchanged, and the dimension still refuses any other undeclared key. + - **What reads it.** Nothing yet. The chart renderer that draws the bucket under this label ships separately, and until it does the bucket keeps the renderer's own default label. The liveness ledger records the key as `planned`, so `os lint` and `os validate` warn when you set it. + - **What to do.** Nothing. No existing key, value or default changes. + +### Patch Changes + +- 448374d: feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) + + Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade <token>` (widening). + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is one install door's answer on an upgrade that would un-make an access choice; the caller's migration is the confirmation the refusal itself names (`confirmAccessUpgrade` with the finding set's token), stated below. --> + + **BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. + + - **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. + - **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. + - **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. + - **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": "<findingSet>"` (CLI: `os package install <package> --confirm-access-upgrade <findingSet>`). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. + - **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). + - **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. + - **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. + + The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. +- 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) + + <!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. --> + + **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. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: 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 compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. + - 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 + + <!-- adr-0087: not-required (no-migration-prescription) A storage constraint at CREATE TABLE: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is removed or renamed; `TENANT_SCOPE_FIELD_DEF` keeps its shape and only its description text is corrected. The remedy is a run-time one, stated below. --> + + **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: <table>.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: '<target>' }` (`Field.lookup('<target>', …)`), 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. +- 60b42cd: fix(spec,plugin-sharing): the `DataEvent.organizationId` description and the sharing-rule Organization help stop describing organization-less rows that 18.x no longer writes + + Clause-②: no (description and help text only: no response, status, outcome, boot verdict or export moves) + Metadata-protocol: none (a schema description and a field's help text; nothing a metadata author writes changes) + + - **`DataEvent.organizationId` (`@objectstack/spec/api`).** The description said every event on a `single`-posture deployment carries no organization, because nothing stamps the column there. Since 18.x the engine stamps every tenant row on a single-organization deployment with its Default Organization, so those events carry it. The key is now described as absent in two cases: a row of an object outside the wall, and every row of a composition that registers no organization object. The schema itself is unchanged (`z.string().min(1).optional()`). + - **`sys_sharing_rule.organization_id` (`@objectstack/plugin-sharing`).** The Setup help said "null = global". A stored rule always carries its organization; a rule that applies to every organization is a declared rule. The help now reads "The organization this rule belongs to.", with the translated locales updated to match. +- 0b8bce4: fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before. + - **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face. + - **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500. + - **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves. +- 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.<flow>.label`, `flows.<flow>.approvals.<node_id>.label` or `objects.<object>.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`) + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored shape or exported declaration is added, removed, renamed or narrowed, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer the engine gives an in-process caller that opts into `strictReadonlyWrites` and sends a value a stored expression then replaces: that write is refused where it completed and was reported. The remedy is the caller's own payload (leave the field out, or send the computed value), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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. +- c8f4c43: fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number + + Clause-②: yes (widening) + Metadata-protocol: none + + A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both. + + - **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged. + - **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does. + - **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy. + - **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number. + - **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged. +- 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 + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration of this package is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the vendor library under the auth endpoints: some requests it accepted are now refused, and some refusals change status. The remedies are operational (restart an in-flight sign-in, correct an SSO provider's issuer), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). --> + + **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". +- e3aa111: docs(spec): `api.enableOpenApi` and the `api.documentation.enabled` refusal name only the OpenAPI document; the retired docs viewer page is no longer described + + Clause-②: no + Metadata-protocol: none + + The REST server no longer serves an HTML viewer page at `GET {basePath}/docs`; it serves the machine-readable OpenAPI document at `GET {basePath}/openapi.json`, gated by `api.enableOpenApi`. The spec text now says only that: + + - **`RestApiConfigSchema.enableOpenApi`.** The description read "Enable OpenAPI 3.1 spec & docs viewer endpoints", and the TSDoc named a Scalar-rendered viewer at `GET {basePath}/docs`. Both now name the one endpoint the flag mounts, `GET {basePath}/openapi.json`. + - **The `api.documentation.enabled` refusal.** Its prescription said `api.enableOpenApi: false` leaves "the `/openapi.json` document and its `/docs` viewer unmounted". It now says the switch leaves the `/openapi.json` document unmounted. The key is refused exactly as before, at the same path, with the same opening sentence; only the closing words changed. + - **The `rest-api-config-dead-keys-retired` upgrade entry** (shown by `os migrate meta` and the upgrade guide) drops the viewer from its replacement and acceptance text the same way. + + No schema accepts or refuses anything new, no default changes, and no export moves. +- 863c580: docs(spec): the `systemFields` docblock no longer says `organization_id` stays NULL on single-tenant stacks + + Clause-②: no (comment and docblock text only: no accept set, response, status, error, outcome or export moves) + Metadata-protocol: none + + The `organization_id` bullet of `ObjectSchema`'s `systemFields` docblock now says what the runtime does: org-scoping stamps the tenant column on insert (under `single` with the Default Organization), and it is never NULL (ADR-0131 D1). Only the column's index is still gated on multi-tenant mode. No schema, `.describe()` text or behavior changes. +- 7f5b19d: docs(spec, skills): the `titleFormat` description and the objectstack-data skill no longer steer a composite record title that must be queried into a formula field + + Clause-②: no + Metadata-protocol: none + + The `titleFormat` description (and its generated copies on the object, metadata and migration reference pages) and the `objectstack-data` skill's `nameField` and `titleFormat` rows told an author to migrate a composite record title to a `formula` field with `returnType: 'text'` designated as `nameField`. A formula value is computed on read and stored nowhere, so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell against it. Both now say what the record-title lint and ADR-0079 D3 already say: a composite title that import, sort or search must match is a stored computed field, a non-`formula` field that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`; a text formula designated as `nameField` stays legal for a title shown for display only. + + The same skill's "Computed column" bullet (`rules/relationships.md`) said a stored field's `expression` is a client compute hint and the sent value is stored verbatim. The engine computes the expression from the row the write stores and stores the result, so a client-sent value never stands; the bullet now says so. + + No key, type, default or accepted value changes; only the text an author reads changes. +- dfab597: feat(spec): `ISharingRuleService` declares the optional `declaredRuleNamed` reader that the catalog activation door uses for declared sharing rules + + Clause-②: yes (widening) + Metadata-protocol: none + + - **The contract member.** `ISharingRuleService` (`@objectstack/spec/contracts`) gains an optional `declaredRuleNamed?(name)` member. It returns `Promise<{ name: string; packageId?: string } | null>`: the rule the application declares under that name, or `null` when nothing servable is declared under it. The answer covers every organization, and it does not depend on whether the activation ledger has the rule switched off. `POST /api/v1/security/_activation/sharing_rule/:name` reads it to confirm the name it is asked to switch. Before this change the door read the method by duck type, and the contract did not mention it. + - **Optional, so no implementer breaks.** An implementation that serves no declared rules can leave it out. A caller probes for it and refuses fail-closed when it is absent. The activation door still answers `503 SERVICE_UNAVAILABLE` in that case, so the door's behaviour does not change. `@objectstack/plugin-sharing`'s `SharingRuleService` already implements it with a compatible signature. + - **The ledger's field description.** On `sys_metadata_activation`, the `metadata_type` description now lists every type the ledger holds: `flow`, `action`, `position`, `permission`, `sharing_rule` and `webhook`. It used to stop at `flow`, `permission`, …. +- 9127f23: fix(lint)!: `os build`, `os validate`, `os lint` and the email template publish door refuse a hole in an HTML body that sits where the mail renderer's encoding does not suit it (#25189) + + Clause-②: no (narrowing: an email template whose `bodyHtml` places a hole outside the supported placements is refused at the three CLI commands and at the publish door, where it passed) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no stored row is read, rewritten or converted, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the verdict the authoring doors give an email template whose HTML body places a hole outside the supported placements. A stored template keeps rendering and is judged at its next publish, and the remedy is the author moving the hole, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a lint verdict, not a declaration (not runtime-interface-only or type-surface-only). --> + + **BREAKING** (an authoring accept set narrows), shipped as `patch` because the change is not to the metadata protocol (maintainer ruling A on #25105). + + The mail renderer encodes a double-braced hole for HTML text and leaves a triple-braced hole unencoded. The template's author decides where each hole sits, and that one encoding is right for some placements and not for others. A new gating authoring rule, `email-template-hole-placement` (`validateEmailTemplateHolePlacements`), classifies every hole in a template's `bodyHtml` by where it sits in the markup and refuses each one outside the supported placements. + + - **Supported:** a double-braced hole in element text, or in a quoted attribute value that is read as plain text; and a triple-braced hole only when it reads a variable the template declares with `type: 'url'` and forms the whole value of a double-quoted `href`. + - **Refused:** every other placement, by class. These are attributes read as a URL, event-handler attributes, `style` attributes, attributes read as markup, unquoted attribute values, holes inside a tag, script- and style-like elements, comments, and SVG or MathML content. A triple-braced hole outside the one supported shape is refused as well. + - **FROM:** such a template passed `os build`, `os validate` and `os lint`, and an active `email_template` write answered 200. + - **TO:** the CLI commands report an `error` finding at `emailTemplates[i].bodyHtml`, and the write answers the existing `422 INVALID_METADATA` with the same issue. The message names the hole, its offset and its placement, and the hint names the supported placements. No new error code is added. + - **Unchanged:** `subject` and `bodyText` are not judged, because the renderer writes them as plain text. A draft save is not judged. A stored template is not migrated, not refused on read and not judged at send or at boot. It keeps rendering until its next publish, and that publish is judged. + - **Measured before crossing:** none of the 76 HTML bodies in the HotCRM reference application, and none of the templates this repository ships, is refused. The five bodies the rule did refuse were documentation examples and test fixtures, and each now uses the supported link shape. + - **`@objectstack/formula` (patch):** the classifier the rule reads, `judgeMailHtmlHoles`, is exported with its supported-placement list and labels. `@objectstack/plugin-email`'s built-in template pin reads the same classifier and holds its hole grammar to the renderer's. + - **`@objectstack/spec` (patch):** one conversion's example templates now declare their link variable with `type: 'url'` and place it in an `href`. No schema, conversion behaviour or export changes. + - **The fix for an author:** move the hole into element text or a quoted plain-text attribute. For a link, declare the variable with `type: 'url'` and write it triple-braced as the whole double-quoted `href` value. +- 24360dc: fix(plugin-sharing): the declared-rule organization walk ends on the rows read, and a row with no readable id answers unknown; texts that still described the retired NULL-organization reading are corrected + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/plugin-sharing`.** The boot's organization walk for declared sharing-rule scopes (`resolveDeclaredRuleScopes`) judged "short page, stop" on the ids it kept, so a full page of `sys_organization` holding one row with no readable `id` ended the walk early and served a partial list, and the boot sweep then revoked the declared-rule grants of every organization past it. The walk now ends on the number of rows READ, and such a row makes the answer unknown: declared rules are served in no organization until the next boot and no declared rule's grant is revoked, with a warning that names the cause. Under the shipped schema every organization row has a text id, so no install is expected to see the new warning. + - **`@objectstack/plugin-security`.** The Organization help on `sys_user_position` and `sys_user_permission_set` no longer says an empty value means "global" or "applies in every org context". A grant or assignment applies only in its own organization, and one with no organization applies nowhere. The four locale bundles carry the new text. + - **`@objectstack/cli`.** `os migrate organization-ownership`'s warning about rows whose owner cannot be derived reads "…while one stays NULL, except on a table whose column is not the tenancy anchor." where it read "(but on a table …)". + - **`@objectstack/spec`.** Wording only, no schema moves: the upgrade guide's `driver-read-scope-follows-tenant-wall` entry names update and delete "by id or by predicate"; the `sharing-rule-declared-mirror-retired` entry no longer paraphrases ADR-0131 D13 as asking an entry of every retirement; `IEmailService`'s `organizationId` TSDoc no longer says an absent value leaves the row unstamped (the engine stamps the derivable organization or refuses the insert); a TSDoc citation in `schedule-organization.zod.ts` names the run store instead of a deleted file. +- 5f30f00: fix(spec)!: the published list-query schemas declare the page window the Data API enforces — a non-negative integer — instead of any number + + Clause-②: no (narrowing) + Metadata-protocol: none + + <!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no metadata type, authorable metadata key, object definition or stored shape is added, removed, renamed or re-shaped. What narrows is two request schemas a Data API caller is validated against (`FindDataRequestSchema`, through `QueryWithTransportSchema` and `QueryTransportParamsSchema`, and `HttpFindQueryParamsSchema`), and every value they now refuse was already refused by the list doors since the page-window normalizer landed. There is nothing for `objectstack migrate meta` to rewrite; the remedy is in the request (send a whole count, or leave the parameter out). --> + + **BREAKING** accept-set narrowing on two published request schemas. It changes what a schema-validated Data API request may carry, not what a metadata author can write, so it ships as `patch` with this banner. + + - **Before.** `FindDataRequestSchema` (the `POST /api/v1/data/:object/query` body) declared `limit` / `offset` / `top` as any number and `$top` / `$skip` / `skip` as any number or string, and `HttpFindQueryParamsSchema` (the `GET /api/v1/data/:object` querystring) declared `top` / `skip` as `z.coerce.number()`. A client generated from them, or a request validated against them, was told `-1`, `1.5` or a blank `?top=` was valid, and the door then refused it with a `400`. + - **Now.** Each of those parameters declares the page window the door enforces: a non-negative integer, or on the string-carrying spellings the digits a querystring carries it as (`'50'`). `-1`, `1.5`, `NaN`, `Infinity`, a blank or whitespace-only string, and a string that is not a count fail the parse. The bound is exported once, as `QueryPageWindowSchema` / `QueryPageWindowParamSchema` (`@objectstack/spec/data`), and the list-query normalizer in `@objectstack/metadata-protocol` now judges every window by that same schema, so the two accept sets are one declaration. + - **One answer moves, and only its code.** On `POST /api/v1/data/:object/query` (and the runtime dispatcher's `POST` twin) a malformed window is now refused by the body schema, like every other malformed body: `400 VALIDATION_FAILED` with `fields[0].field` naming `query.limit` / `query.offset`, where it was `400 INVALID_REQUEST` from the normalizer. Still a `400`, still before any read. `GET /api/v1/data/:object` has no body schema and answers `400 INVALID_REQUEST` naming the parameter as before. + - **Unchanged.** A well-formed window parses and pages exactly as before, `0` included (`$top=0` asks for the total alone). Windows inside `expand` and the engine and driver contracts (`QuerySchema`, `EngineQueryOptionsSchema`) are not narrowed: no door refuses those. + + **The remedy.** Send the window as a whole count of zero or more, or leave the parameter out. +- 7db1697: fix(spec): `os migrate meta --stored` lists a stored object bound `external` to a stored or registered managed datasource + + Clause-②: yes (widening) + + Metadata-protocol: none + + The stored-shape listing for `object-external-binding-managed-datasource-refused` resolved an object's named datasource only from the stack being converted. A stored object row reaches the conversion chain alone, so the listing judged only a binding on the primary datasource (`'default'`), while the object save door also refuses one on a datasource stored or registered beside the object. The stored-metadata pass therefore did not list a stored object row bound `external` to a managed datasource, the row whose next save is refused with `422 INVALID_METADATA`. + + - **Now:** for each stored `object` row carrying `external` and naming a datasource, the pass reads that datasource the way the object save door does (the environment's stored row, else the definition the code layer registers; both go through one read) and hands it to the chain. The row is listed under `rows[].todos` with this entry's id exactly when that door refuses its next save. A datasource declared `schemaMode: 'external'` or `'validate-only'`, and a datasource no layer holds, give no listing, as at the door. Nothing is rewritten. + - **`@objectstack/spec`:** `ApplyConversionsOptions` (and so `StoredConversionOptions` for `applyConversionsToStoredItem`) gains an optional `knownDatasources`, a map of datasource definitions by name that the caller's door can see beyond the stack. Only the refused-shape listing reads it, so only when an `onTodo` sink is passed. A datasource the stack declares is read from the stack first. Without it, behaviour is unchanged. +- d12e650: feat: `ExecutionStatus` gains `missed`, the run status for a scheduled flow's due time that passed while no kernel was ticking + + Clause-②: yes (widening) + Metadata-protocol: none + + `ExecutionStatus` gains a tenth member, `missed`, appended last so every reader that indexes `.options` keeps its positions. It records a scheduled flow's due time that passed while no kernel was ticking, written once by the next kernel that finds the gap, one entry per missed due time. It is terminal: the run never started, is never resumed and is never replayed. It is distinct from `cancelled` (nobody cancelled it) and from `failed` (nothing ran). + + No field is added. On an `ExecutionLog`, `startedAt` is the due time and `completedAt` the detection time; `durationMs` and `refusalMessage` are absent and `steps` is empty. On the stored run row, `started_at` and `finished_at` carry the same two instants, and `duration_ms`, `error` and `refusal_message` are absent. + + `ListRunsRequestSchema.status` and the REST `?status=` filter read the enum, so both accept `missed`. `AutomationResult.status`, `TriggerFlowResponseSchema.data.status` and `JobExecutionStatus` do not change. + + `@objectstack/plugin-approvals`: `RUN_STATUS_LIVENESS` maps `missed` to `terminal`, so an approval pending on a missed run is swept like one pending on any other finished run. + + This release only declares the value. The engine writes `missed` rows in a later release. +- 830a63a: feat(analytics): a dataset answer carries each dimension column's null-bucket name, and System Overview names its null user bucket "System" + + Clause-②: no + Metadata-protocol: none + + A dataset dimension's `nullLabel` now reaches the renderer. The chart reads every column's display metadata from the dataset answer's `fields[]` and never fetches the dataset document, so the name rides that descriptor: + + - **`@objectstack/spec`:** `AnalyticsResult.fields[]` and its mirror `AnalyticsResultResponseSchema` gain the optional response member `nullLabel?: string`, the display name of a dimension column's null bucket. It is a response member only; nothing an author writes changes. + - **`@objectstack/service-analytics`:** the column-description pass shared by the live query and the draft-data preview sets `fields[].nullLabel` on a dimension column whose dataset dimension declares `nullLabel`, resolved to a string at the request locale by the same resolver as `label`. A dimension without one, and every measure column, carry no `nullLabel` key, so those answers are byte-identical to before. + - **`@objectstack/platform-objects`:** the `sys_audit_log_metrics` dataset's `user_id` dimension declares `nullLabel: { en: 'System', 'zh-CN': '系统', 'ja-JP': 'システム', 'es-ES': 'Sistema' }`. The rows the platform writes itself carry no user, and System Overview's "Events by User" drew them, its largest bar, under the renderer's default "(None)". Once the console reads `fields[].nullLabel`, that bar reads "System". +- bdf762c: fix: Setup forms show option labels instead of machine values on Business Units, Sharing Rules and Datasources + + Clause-②: no + Metadata-protocol: none + + - **`@objectstack/platform-objects`:** `sys_business_unit.kind` declared its options as bare strings, so each label equalled its stored value and the form offered `cost_center`. The options now carry labels (Company, Division, Department, Office, Cost Center). The stored values are unchanged. + - **`@objectstack/plugin-sharing`:** `sys_sharing_rule.recipient_type` and `access_level` declared bare-string options too. They now carry labels (User, Team, Business unit, Position, Business unit and subordinates, Field; Read, Read and edit), which are the names the Recipient Type help text already used. `sys_record_share` declares the same recipient and access vocabulary, so its `recipient_type` (User, Group, Position, Business unit and subordinates) and `access_level` options carry the same labels. The stored values are unchanged. + - **`@objectstack/spec`:** the `autoMigrate` key of the SQLite, PostgreSQL and MySQL driver configs had no JSON-Schema `title`, so the New datasource form labelled the field with the raw key `autoMigrate`. It is now titled "Auto-migrate schema". + + The English labels flow into the generated translation bundles; the existing zh-CN, ja-JP and es-ES option translations are kept. +- 27b3f1f: feat: the position list can say who holds each position and which permission sets it grants, read where the resolver enforces both + + Clause-②: yes (widening: `GET /meta/position?include=grants` adds two read-only served keys per position, `_holderCount` and `_grantedPermissionSets`; `ISecurityService` gains the optional `describePositionGrants`) + Metadata-protocol: none + + Setup → Positions could not answer "who is a sales manager, and what do they get?". The list now can, and its answer is the enforcement path's, not a second copy of it. + + - **`GET /meta/position?include=grants`** (`@objectstack/rest`, both transports through the shared list chain). Each position gains `_holderCount` and `_grantedPermissionSets: [{ name, via }]`. Without `?include=grants` the list is unchanged, so the pickers and assignment pages that read it pay no extra read. + - **`via: 'definition'`** marks a set the position's `permissionSets` names. **`via: 'name'`** marks the set that shares the position's name and that the definition does not list. The position-name fold grants it anyway (the `position_name_fold_grant` warning), so the list shows it rather than hiding a grant that is in force. + - **The sets** are the ones the resolver grants a holder of that position alone. A position or set switched off in the activation ledger grants nothing, and a name the catalog holds no set for confers nothing. + - **`_holderCount`** counts the people who hold the position in the caller's active organization, by the rule the resolver uses to give a caller positions: `sys_user_position` rows in their validity window, membership roles, and `everyone` for every active member. A switched-off position keeps its holders (ADR-0049). The count is `null`, never `0`, with no active organization, for `platform_admin` and `guest`, after a truncated read, and for a caller who may not read `sys_user_position`. + - **`ISecurityService.describePositionGrants`** (`@objectstack/spec` contract, served by `@objectstack/plugin-security`) is optional. A security service without it serves the list undecorated, never with empty values. + - **`@objectstack/core`** exports `readPositionGrantsInForce` and `countPositionHolders`. `resolveUserAuthzGrants` now asks the same two rules for a caller, with no change in what it resolves. + ## 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..e711bf04376 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/trigger-api +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..f5e1c173edb 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,71 @@ # @objectstack/plugin-trigger-record-change +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..c7f63135cc1 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,75 @@ # @objectstack/plugin-trigger-schedule +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [d196748] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [303aef0] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @objectstack/spec@18.1.0 + - @objectstack/core@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..34ea0ede7bd 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,63 @@ # @objectstack/types +## 18.1.0 + +### Patch Changes + +- Updated dependencies [448374d] +- 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 [60b42cd] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0b8bce4] +- Updated dependencies [2069b81] +- 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 [2354058] +- Updated dependencies [edcc8ce] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [02ad31a] +- Updated dependencies [03966ce] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [24360dc] +- Updated dependencies [bda53bb] +- Updated dependencies [b964ea1] +- Updated dependencies [5f30f00] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [f4112a7] +- Updated dependencies [303aef0] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [7c68b10] + - @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..0c042e54724 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,218 @@ # @objectstack/verify +## 18.1.0 + +### Patch Changes + +- Updated dependencies [54df640] +- Updated dependencies [448374d] +- Updated dependencies [94842ba] +- Updated dependencies [7cacad5] +- Updated dependencies [df8bb40] +- Updated dependencies [0091d84] +- Updated dependencies [d5d8146] +- 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 [72a302a] +- Updated dependencies [fcb40ad] +- Updated dependencies [ba69b16] +- Updated dependencies [865d2c8] +- Updated dependencies [865d2c8] +- Updated dependencies [2941a19] +- Updated dependencies [3b3830c] +- Updated dependencies [abc25a3] +- Updated dependencies [e282f32] +- Updated dependencies [644e4b2] +- Updated dependencies [3d8960c] +- Updated dependencies [0715fac] +- Updated dependencies [60b42cd] +- Updated dependencies [ea08ce5] +- Updated dependencies [19e5338] +- Updated dependencies [9cfc575] +- Updated dependencies [b9a79ef] +- Updated dependencies [63085c3] +- Updated dependencies [0603bfa] +- Updated dependencies [0b8bce4] +- 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 [2069b81] +- 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 [42b60b9] +- 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 [c24203f] +- Updated dependencies [d6a5fa3] +- Updated dependencies [27dc53b] +- Updated dependencies [28c9a2c] +- Updated dependencies [4bf079c] +- Updated dependencies [0f1eeee] +- Updated dependencies [f46ef4d] +- Updated dependencies [3782b2d] +- Updated dependencies [2354058] +- Updated dependencies [ba244f1] +- Updated dependencies [edcc8ce] +- Updated dependencies [e5f194b] +- Updated dependencies [2aaee3b] +- Updated dependencies [a39389e] +- Updated dependencies [9340181] +- Updated dependencies [9340181] +- Updated dependencies [2e242bb] +- Updated dependencies [fccbd9d] +- Updated dependencies [c8f4c43] +- Updated dependencies [4f97aeb] +- Updated dependencies [c4b49c3] +- Updated dependencies [db9c02a] +- Updated dependencies [284e048] +- Updated dependencies [c6bdcac] +- Updated dependencies [02ad31a] +- Updated dependencies [0148071] +- Updated dependencies [03966ce] +- Updated dependencies [1b3b65e] +- Updated dependencies [331613c] +- Updated dependencies [5801a4f] +- Updated dependencies [425c743] +- Updated dependencies [d609f60] +- Updated dependencies [6b44efa] +- Updated dependencies [a1e51d6] +- Updated dependencies [e3aa111] +- Updated dependencies [863c580] +- Updated dependencies [7f5b19d] +- Updated dependencies [8010b64] +- Updated dependencies [fb7501b] +- Updated dependencies [504d8b3] +- Updated dependencies [14e7239] +- Updated dependencies [fe0ec2b] +- Updated dependencies [dfab597] +- Updated dependencies [9127f23] +- Updated dependencies [1f78b64] +- Updated dependencies [bc5daf0] +- Updated dependencies [3a440b0] +- Updated dependencies [279fdcf] +- Updated dependencies [24360dc] +- Updated dependencies [8dfd126] +- Updated dependencies [bda53bb] +- Updated dependencies [b62f959] +- Updated dependencies [b964ea1] +- Updated dependencies [72fad92] +- Updated dependencies [d196748] +- Updated dependencies [9d60b42] +- Updated dependencies [829cb77] +- Updated dependencies [5f30f00] +- Updated dependencies [09e07c2] +- Updated dependencies [7db1697] +- Updated dependencies [f5b6b3f] +- Updated dependencies [c109bfc] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [f4112a7] +- Updated dependencies [db1c7cc] +- Updated dependencies [258f4e6] +- Updated dependencies [882372d] +- Updated dependencies [303aef0] +- Updated dependencies [eee2f0d] +- Updated dependencies [b3034ee] +- Updated dependencies [d12e650] +- Updated dependencies [830a63a] +- Updated dependencies [ee63b47] +- Updated dependencies [bdf762c] +- Updated dependencies [27b3f1f] +- Updated dependencies [3306cad] +- Updated dependencies [c898bd8] +- Updated dependencies [431327b] +- Updated dependencies [97aa34e] +- Updated dependencies [8a49e37] +- Updated dependencies [7c68b10] +- Updated dependencies [f7d7403] + - @objectstack/plugin-security@18.1.0 + - @objectstack/plugin-sharing@18.1.0 + - @objectstack/spec@18.1.0 + - @objectstack/core@18.1.0 + - @objectstack/runtime@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/service-analytics@18.1.0 + - @objectstack/rest@18.1.0 + - @objectstack/plugin-pinyin-search@18.1.0 + - @objectstack/mcp@18.1.0 + - @objectstack/metadata@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 04f3fd33c14..468a5b3fd22 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 ---