From fa3cad949e56a84f767512f72c689f626e99703b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:15:57 +0000 Subject: [PATCH] chore: version packages --- .../15052-search-fields-docblock-icontains.md | 38 - .../15110-retired-element-node-refusal.md | 76 - .../15117-action-engine-delete-id-array.md | 32 - .../15141-cluster-doc-pointer-site-urls.md | 39 - ...437-validation-messages-migration-route.md | 51 - ...6236-formula-return-type-measure-column.md | 49 - .../16274-initial-completion-history-guard.md | 23 - .changeset/16746-connect-agent-account-nav.md | 50 - ...7081-dev-admin-banner-says-what-it-sees.md | 50 - .changeset/17124-daterange-array-arm-arity.md | 51 - ...ct-grid-export-options-describe-members.md | 52 - .../17175-non-raising-table-presence-probe.md | 81 - .../17177-seed-summary-declares-its-scope.md | 24 - ...7265-nested-hook-refusal-is-a-rejection.md | 42 - .changeset/17328-colspan-rule-withdrawn.md | 39 - .../17333-date-macros-header-adr-0053.md | 49 - ...ulti-valued-boolean-contains-membership.md | 36 - .../17416-packages-get-version-scope.md | 51 - .../17511-i18n-extract-region-screens.md | 45 - .../17562-initial-failure-history-guard.md | 26 - .../17579-approver-type-manager-describe.md | 43 - ...586-multi-valued-boolean-read-inversion.md | 29 - ...17610-notification-dispatcher-idle-cost.md | 17 - .changeset/17623-http-dispatcher-idle-cost.md | 26 - .changeset/17634-http-ack-claim-credential.md | 7 - .changeset/7898-auth-gate-fail-close.md | 50 - .../action-confirmation-gate-enforced.md | 28 - ...min-create-user-reads-membership-policy.md | 21 - .../adr-0112-envelope-refusal-declaration.md | 17 - .../analytics-compareto-kind-refusal.md | 54 - ...tics-dataset-query-selection-door-parse.md | 39 - .../analytics-daterange-driver-alignment.md | 89 - ...cs-inline-dataset-object-read-admission.md | 31 - ...tics-reference-dimension-display-labels.md | 19 - .../analytics-row-scope-bridge-three-way.md | 30 - .../analytics-row-scope-refusal-envelope.md | 16 - ...nalytics-sqldialect-declared-vocabulary.md | 63 - ...tics-time-dimension-granularity-buckets.md | 120 - ...pprovers-manager-rung-may-resolve-empty.md | 18 - ...pprovals-terminal-run-status-exhaustive.md | 13 - ...tifact-granted-permissions-load-binding.md | 15 - .../audit-write-failure-cause-keyed-report.md | 13 - .changeset/auth-gate-allowlist-anchored.md | 13 - .../auth-manager-single-flight-instance.md | 15 - ...tomation-run-declaration-truth-residues.md | 45 - .../basepath-normaliser-consolidation.md | 33 - .../better-sqlite3-peer-record-remeasured.md | 44 - ...-node-condition-refused-at-registration.md | 104 - .changeset/cli-register-requires-name.md | 16 - .../client-adopts-rotated-session-token.md | 27 - .../client-environments-delete-purge.md | 18 - ...et-active-member-names-the-organisation.md | 33 - ...t-get-session-envelope-and-refresh-read.md | 64 - .../client-invite-role-default-member.md | 24 - .../client-packages-get-single-true-type.md | 27 - ...onfig-refusal-throws-so-json-faces-emit.md | 48 - .changeset/cron-typed-positions-retired.md | 136 - ...rd-chartconfig-liveness-row-re-anchored.md | 13 - .../dashboard-item-level-property-names.md | 59 - ...hboard-stageorder-doc-names-only-funnel.md | 14 - .../dashboard-stageorder-gated-to-funnel.md | 55 - .../data-migration-flag-columns-moved-at.md | 14 - ...et-measure-aggregate-field-type-refused.md | 117 - .../dataset-select-dimension-option-i18n.md | 36 - .changeset/date-range-preset-window-extent.md | 16 - .changeset/declared-refusal-relay.md | 18 - ...ving-aggregate-nonnumeric-field-refused.md | 92 - .../discovery-services-route-follows-mount.md | 18 - ...ig-registry-off-vocabulary-lookup-guard.md | 49 - .changeset/driver-sql-doors-declared-types.md | 15 - .../driver-turso-doors-declared-types.md | 13 - .../driver-turso-remote-declared-indexes.md | 21 - ...engine-text-operator-declared-type-door.md | 66 - .changeset/engine-verb-result-declarations.md | 35 - .../engine-write-failure-log-level-warn.md | 37 - .../error-code-ledger-boot-refusal-prose.md | 13 - .changeset/example-caption-fence-assertion.md | 30 - ...notnull-prescribes-storage-not-required.md | 19 - ...field-type-refused-at-registration-door.md | 38 - .changeset/file-family-bare-id-column.md | 26 - .../filter-operator-schema-projection.md | 34 - ...r-orthography-binding-and-object-blocks.md | 60 - .../flow-edge-condition-evaluated-slot.md | 108 - ...dmission-tenancy-posture-classification.md | 59 - ...e-migration-emits-declared-unique-index.md | 64 - .changeset/generate-name-charset-gate.md | 13 - .../generator-declared-column-default.md | 51 - .changeset/grouping-field-non-padded.md | 48 - .../hono-adapter-declared-envelope-render.md | 55 - .changeset/hook-input-is-the-persist-image.md | 55 - .../hook-previous-row-invariant-rewrite.md | 16 - .../hook-withheld-readonly-key-diagnostic.md | 37 - ...-write-set-finding-path-lowered-handler.md | 41 - ...st-importer-location-install-diagnostic.md | 11 - ...8n-check-platform-bucket-and-app-gating.md | 63 - ...i18n-inline-locale-map-population-count.md | 26 - .../i18n-slotted-pages-and-global-filters.md | 24 - .changeset/id-field-retirement-declared.md | 11 - .../import-protocol-implementor-typed.md | 13 - .changeset/import-protocol-typed-args.md | 53 - .../import-runner-canonical-query-ast.md | 13 - .changeset/insert-check-post-image.md | 32 - .../iso-from-valid-date-family-collapse.md | 108 - .../link-finder-declared-location-axis.md | 13 - .../lint-injected-temporal-column-types.md | 41 - ...listview-calendar-type-axis-scope-16577.md | 16 - .../lookup-picker-reader-prose-remeasured.md | 13 - .changeset/lookup-picker-reference-only.md | 25 - .changeset/lookup-reference-target-gate.md | 25 - ...custom-connector-reaches-from-anthropic.md | 14 - .../mcp-refuse-undeclared-tool-arguments.md | 44 - .changeset/mcp-token-human-principal.md | 27 - .../memory-driver-tenant-scope-refusal.md | 21 - ...ry-matcher-scalar-comparand-array-value.md | 19 - .../memory-unique-sticky-tenancy-opt-out.md | 68 - ...ate-route-engine-outage-distinguishable.md | 17 - ...eta-types-action-schema-no-longer-empty.md | 31 - .../migrate-meta-default-range-terminus.md | 20 - .../migrate-meta-protocol-version-key.md | 65 - .changeset/nested-strand-chain-restore.md | 64 - ...notify-zero-delivery-is-distinguishable.md | 15 - .changeset/numeric-column-representation.md | 70 - .changeset/oauth-agent-runs-as-the-user.md | 40 - ...register-declares-only-honoured-members.md | 31 - .changeset/object-block-sort-item-array.md | 69 - .../objectql-aggregate-inmemory-rows-ast.md | 16 - ...ctql-scoped-repository-declared-returns.md | 21 - .changeset/one-app-rule-adr-0019-citation.md | 17 - .../operator-facing-raw-exec-cause-text.md | 90 - .changeset/organizations-open-core-prose.md | 23 - .changeset/osv-advisory-bumps-2026-09.md | 19 - ...ance-stops-prescribing-assignedprofiles.md | 13 - .../permissions-alias-hosts-justification.md | 13 - ...persist-terminal-run-status-distinction.md | 15 - .changeset/plain-donkeys-repeat.md | 56 - .../platform-admin-existing-holder-scan.md | 17 - .../platform-admin-promotion-selection.md | 16 - ...lugin-auth-admin-import-canonical-query.md | 12 - .../plugin-security-read-fault-vs-empty.md | 13 - .../plugin-version-honest-grammar-claim.md | 23 - .changeset/plugin-version-semver-grammar.md | 45 - .changeset/preview-avg-empty-group-null.md | 61 - .../preview-count-over-field-non-null.md | 34 - .../protection-block-unknown-key-refusal.md | 25 - .changeset/protocol-version-gap-key-rename.md | 46 - ...honours-or-refuses-declared-manifest-id.md | 21 - .changeset/quiet-pugs-tickle.md | 24 - .changeset/raw-mount-declared-envelope.md | 9 - .../read-audit-preserve-view-instant.md | 25 - ...ly-create-side-bucket-exclusion-narrows.md | 94 - .../readonly-insert-superseded-prose.md | 10 - ...repeater-item-schema-titles-class-guard.md | 76 - .../reserved-identity-name-position-guard.md | 18 - ...e-adr-0030-notification-event-migration.md | 54 - .changeset/retire-list-view-page-mount.md | 96 - ...ible-org-ids-resolved-into-variable-bag.md | 78 - .changeset/rls-predicate-references.md | 24 - ...eserved-membership-keys-refused-by-name.md | 29 - ...eclared-column-denies-in-every-position.md | 29 - .changeset/rollup-non-numeric-aggregand.md | 14 - ...time-gate-overlay-redefinition-universe.md | 20 - .changeset/s3-adapter-key-namespace.md | 26 - ...andbox-crash-outranks-declared-code-arm.md | 43 - .../schedule-trigger-acting-organization.md | 129 - .changeset/scoped-packages-dispatcher-door.md | 18 - .../scoped-sdk-honours-metadata-prefix.md | 40 - .../sdui-parser-stageorder-funnel-only.md | 13 - .changeset/security-fls-unknown-field.md | 16 - .changeset/seed-locale-producer-wiring.md | 20 - .changeset/serve-org-remedy-defers.md | 9 - .../single-posture-organization-census.md | 13 - .../solution-blueprint-module-header.md | 23 - ...pec-approval-continue-restored-contract.md | 9 - .../spec-cloud-provided-package-version.md | 28 - .changeset/spec-cloud-subpath-retired.md | 55 - ...-functional-completeness-symbol-anchors.md | 29 - .changeset/spicy-pears-count.md | 7 - .changeset/spotty-jars-shave.md | 11 - ...dalone-plugin-scaffold-unscoped-private.md | 30 - .../standalone-stamp-comment-accuracy.md | 18 - .changeset/strict-env-scope-roots-dyn.md | 88 - .../sys-user-role-prose-retired-action.md | 13 - ...mporal-text-operator-declared-type-gate.md | 103 - .../translate-flow-walks-adr-0031-regions.md | 34 - .../two-factor-verify-echoes-live-user-row.md | 16 - ...date-refuses-blank-structural-condition.md | 50 - ...lue-domain-note-settings-door-repointed.md | 27 - .../value-envelope-nullish-attribution.md | 13 - .changeset/verify-in-process-handle.md | 26 - .../visiblewhen-app-scope-root-prose.md | 17 - 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 | 74 + examples/app-crm/package.json | 2 +- examples/app-multi-package/CHANGELOG.md | 67 + examples/app-multi-package/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 89 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 106 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 82 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 22 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 69 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 69 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 69 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 1111 ++++++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 82 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 318 +++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 132 + packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 72 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 72 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 72 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 72 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 2 + packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 415 +++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 44 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 370 +++ packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 77 + packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 504 ++++ packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 178 ++ .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 204 ++ packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 153 ++ packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 712 +++++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 234 ++ packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 160 ++ packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 8 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 594 +++++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 396 +++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 664 +++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 67 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 162 ++ packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 67 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 73 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 73 + .../plugins/knowledge-ragflow/package.json | 2 +- packages/plugins/organizations/CHANGELOG.md | 86 + packages/plugins/organizations/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 152 ++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 122 + packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 288 ++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 150 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 90 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 151 ++ .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 31 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 74 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 342 +++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 143 + packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 78 + packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 153 ++ packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 67 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 12 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 385 +++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 335 +++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 14 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 854 ++++++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 476 ++++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 73 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 68 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 72 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 141 + .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 77 + packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 74 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 72 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 120 + .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 75 + .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 74 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 74 + .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 128 + .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 81 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 154 ++ .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 2368 +++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 72 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 72 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 197 ++ .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 179 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 198 ++ packages/verify/package.json | 2 +- 350 files changed, 16195 insertions(+), 7074 deletions(-) delete mode 100644 .changeset/15052-search-fields-docblock-icontains.md delete mode 100644 .changeset/15110-retired-element-node-refusal.md delete mode 100644 .changeset/15117-action-engine-delete-id-array.md delete mode 100644 .changeset/15141-cluster-doc-pointer-site-urls.md delete mode 100644 .changeset/15437-validation-messages-migration-route.md delete mode 100644 .changeset/16236-formula-return-type-measure-column.md delete mode 100644 .changeset/16274-initial-completion-history-guard.md delete mode 100644 .changeset/16746-connect-agent-account-nav.md delete mode 100644 .changeset/17081-dev-admin-banner-says-what-it-sees.md delete mode 100644 .changeset/17124-daterange-array-arm-arity.md delete mode 100644 .changeset/17166-object-grid-export-options-describe-members.md delete mode 100644 .changeset/17175-non-raising-table-presence-probe.md delete mode 100644 .changeset/17177-seed-summary-declares-its-scope.md delete mode 100644 .changeset/17265-nested-hook-refusal-is-a-rejection.md delete mode 100644 .changeset/17328-colspan-rule-withdrawn.md delete mode 100644 .changeset/17333-date-macros-header-adr-0053.md delete mode 100644 .changeset/17343-multi-valued-boolean-contains-membership.md delete mode 100644 .changeset/17416-packages-get-version-scope.md delete mode 100644 .changeset/17511-i18n-extract-region-screens.md delete mode 100644 .changeset/17562-initial-failure-history-guard.md delete mode 100644 .changeset/17579-approver-type-manager-describe.md delete mode 100644 .changeset/17586-multi-valued-boolean-read-inversion.md delete mode 100644 .changeset/17610-notification-dispatcher-idle-cost.md delete mode 100644 .changeset/17623-http-dispatcher-idle-cost.md delete mode 100644 .changeset/17634-http-ack-claim-credential.md delete mode 100644 .changeset/7898-auth-gate-fail-close.md delete mode 100644 .changeset/action-confirmation-gate-enforced.md delete mode 100644 .changeset/admin-create-user-reads-membership-policy.md delete mode 100644 .changeset/adr-0112-envelope-refusal-declaration.md delete mode 100644 .changeset/analytics-compareto-kind-refusal.md delete mode 100644 .changeset/analytics-dataset-query-selection-door-parse.md delete mode 100644 .changeset/analytics-daterange-driver-alignment.md delete mode 100644 .changeset/analytics-inline-dataset-object-read-admission.md delete mode 100644 .changeset/analytics-reference-dimension-display-labels.md delete mode 100644 .changeset/analytics-row-scope-bridge-three-way.md delete mode 100644 .changeset/analytics-row-scope-refusal-envelope.md delete mode 100644 .changeset/analytics-sqldialect-declared-vocabulary.md delete mode 100644 .changeset/analytics-time-dimension-granularity-buckets.md delete mode 100644 .changeset/approval-approvers-manager-rung-may-resolve-empty.md delete mode 100644 .changeset/approvals-terminal-run-status-exhaustive.md delete mode 100644 .changeset/artifact-granted-permissions-load-binding.md delete mode 100644 .changeset/audit-write-failure-cause-keyed-report.md delete mode 100644 .changeset/auth-gate-allowlist-anchored.md delete mode 100644 .changeset/auth-manager-single-flight-instance.md delete mode 100644 .changeset/automation-run-declaration-truth-residues.md delete mode 100644 .changeset/basepath-normaliser-consolidation.md delete mode 100644 .changeset/better-sqlite3-peer-record-remeasured.md delete mode 100644 .changeset/blank-node-condition-refused-at-registration.md delete mode 100644 .changeset/cli-register-requires-name.md delete mode 100644 .changeset/client-adopts-rotated-session-token.md delete mode 100644 .changeset/client-environments-delete-purge.md delete mode 100644 .changeset/client-get-active-member-names-the-organisation.md delete mode 100644 .changeset/client-get-session-envelope-and-refresh-read.md delete mode 100644 .changeset/client-invite-role-default-member.md delete mode 100644 .changeset/client-packages-get-single-true-type.md delete mode 100644 .changeset/config-refusal-throws-so-json-faces-emit.md delete mode 100644 .changeset/cron-typed-positions-retired.md delete mode 100644 .changeset/dashboard-chartconfig-liveness-row-re-anchored.md delete mode 100644 .changeset/dashboard-item-level-property-names.md delete mode 100644 .changeset/dashboard-stageorder-doc-names-only-funnel.md delete mode 100644 .changeset/dashboard-stageorder-gated-to-funnel.md delete mode 100644 .changeset/data-migration-flag-columns-moved-at.md delete mode 100644 .changeset/dataset-measure-aggregate-field-type-refused.md delete mode 100644 .changeset/dataset-select-dimension-option-i18n.md delete mode 100644 .changeset/date-range-preset-window-extent.md delete mode 100644 .changeset/declared-refusal-relay.md delete mode 100644 .changeset/deriving-aggregate-nonnumeric-field-refused.md delete mode 100644 .changeset/discovery-services-route-follows-mount.md delete mode 100644 .changeset/driver-config-registry-off-vocabulary-lookup-guard.md delete mode 100644 .changeset/driver-sql-doors-declared-types.md delete mode 100644 .changeset/driver-turso-doors-declared-types.md delete mode 100644 .changeset/driver-turso-remote-declared-indexes.md delete mode 100644 .changeset/engine-text-operator-declared-type-door.md delete mode 100644 .changeset/engine-verb-result-declarations.md delete mode 100644 .changeset/engine-write-failure-log-level-warn.md delete mode 100644 .changeset/error-code-ledger-boot-refusal-prose.md delete mode 100644 .changeset/example-caption-fence-assertion.md delete mode 100644 .changeset/field-notnull-prescribes-storage-not-required.md delete mode 100644 .changeset/field-type-refused-at-registration-door.md delete mode 100644 .changeset/file-family-bare-id-column.md delete mode 100644 .changeset/filter-operator-schema-projection.md delete mode 100644 .changeset/filter-orthography-binding-and-object-blocks.md delete mode 100644 .changeset/flow-edge-condition-evaluated-slot.md delete mode 100644 .changeset/fold-admission-tenancy-posture-classification.md delete mode 100644 .changeset/generate-migration-emits-declared-unique-index.md delete mode 100644 .changeset/generate-name-charset-gate.md delete mode 100644 .changeset/generator-declared-column-default.md delete mode 100644 .changeset/grouping-field-non-padded.md delete mode 100644 .changeset/hono-adapter-declared-envelope-render.md delete mode 100644 .changeset/hook-input-is-the-persist-image.md delete mode 100644 .changeset/hook-previous-row-invariant-rewrite.md delete mode 100644 .changeset/hook-withheld-readonly-key-diagnostic.md delete mode 100644 .changeset/hook-write-set-finding-path-lowered-handler.md delete mode 100644 .changeset/host-importer-location-install-diagnostic.md delete mode 100644 .changeset/i18n-check-platform-bucket-and-app-gating.md delete mode 100644 .changeset/i18n-inline-locale-map-population-count.md delete mode 100644 .changeset/i18n-slotted-pages-and-global-filters.md delete mode 100644 .changeset/id-field-retirement-declared.md delete mode 100644 .changeset/import-protocol-implementor-typed.md delete mode 100644 .changeset/import-protocol-typed-args.md delete mode 100644 .changeset/import-runner-canonical-query-ast.md delete mode 100644 .changeset/insert-check-post-image.md delete mode 100644 .changeset/iso-from-valid-date-family-collapse.md delete mode 100644 .changeset/link-finder-declared-location-axis.md delete mode 100644 .changeset/lint-injected-temporal-column-types.md delete mode 100644 .changeset/listview-calendar-type-axis-scope-16577.md delete mode 100644 .changeset/lookup-picker-reader-prose-remeasured.md delete mode 100644 .changeset/lookup-picker-reference-only.md delete mode 100644 .changeset/lookup-reference-target-gate.md delete mode 100644 .changeset/mcp-readme-custom-connector-reaches-from-anthropic.md delete mode 100644 .changeset/mcp-refuse-undeclared-tool-arguments.md delete mode 100644 .changeset/mcp-token-human-principal.md delete mode 100644 .changeset/memory-driver-tenant-scope-refusal.md delete mode 100644 .changeset/memory-matcher-scalar-comparand-array-value.md delete mode 100644 .changeset/memory-unique-sticky-tenancy-opt-out.md delete mode 100644 .changeset/meta-state-route-engine-outage-distinguishable.md delete mode 100644 .changeset/meta-types-action-schema-no-longer-empty.md delete mode 100644 .changeset/migrate-meta-default-range-terminus.md delete mode 100644 .changeset/migrate-meta-protocol-version-key.md delete mode 100644 .changeset/nested-strand-chain-restore.md delete mode 100644 .changeset/notify-zero-delivery-is-distinguishable.md delete mode 100644 .changeset/numeric-column-representation.md delete mode 100644 .changeset/oauth-agent-runs-as-the-user.md delete mode 100644 .changeset/oauth-register-declares-only-honoured-members.md delete mode 100644 .changeset/object-block-sort-item-array.md delete mode 100644 .changeset/objectql-aggregate-inmemory-rows-ast.md delete mode 100644 .changeset/objectql-scoped-repository-declared-returns.md delete mode 100644 .changeset/one-app-rule-adr-0019-citation.md delete mode 100644 .changeset/operator-facing-raw-exec-cause-text.md delete mode 100644 .changeset/organizations-open-core-prose.md delete mode 100644 .changeset/osv-advisory-bumps-2026-09.md delete mode 100644 .changeset/page-guidance-stops-prescribing-assignedprofiles.md delete mode 100644 .changeset/permissions-alias-hosts-justification.md delete mode 100644 .changeset/persist-terminal-run-status-distinction.md delete mode 100644 .changeset/plain-donkeys-repeat.md delete mode 100644 .changeset/platform-admin-existing-holder-scan.md delete mode 100644 .changeset/platform-admin-promotion-selection.md delete mode 100644 .changeset/plugin-auth-admin-import-canonical-query.md delete mode 100644 .changeset/plugin-security-read-fault-vs-empty.md delete mode 100644 .changeset/plugin-version-honest-grammar-claim.md delete mode 100644 .changeset/plugin-version-semver-grammar.md delete mode 100644 .changeset/preview-avg-empty-group-null.md delete mode 100644 .changeset/preview-count-over-field-non-null.md delete mode 100644 .changeset/protection-block-unknown-key-refusal.md delete mode 100644 .changeset/protocol-version-gap-key-rename.md delete mode 100644 .changeset/publish-honours-or-refuses-declared-manifest-id.md delete mode 100644 .changeset/quiet-pugs-tickle.md delete mode 100644 .changeset/raw-mount-declared-envelope.md delete mode 100644 .changeset/read-audit-preserve-view-instant.md delete mode 100644 .changeset/readonly-create-side-bucket-exclusion-narrows.md delete mode 100644 .changeset/readonly-insert-superseded-prose.md delete mode 100644 .changeset/repeater-item-schema-titles-class-guard.md delete mode 100644 .changeset/reserved-identity-name-position-guard.md delete mode 100644 .changeset/retire-adr-0030-notification-event-migration.md delete mode 100644 .changeset/retire-list-view-page-mount.md delete mode 100644 .changeset/rls-accessible-org-ids-resolved-into-variable-bag.md delete mode 100644 .changeset/rls-predicate-references.md delete mode 100644 .changeset/rls-reserved-membership-keys-refused-by-name.md delete mode 100644 .changeset/rls-undeclared-column-denies-in-every-position.md delete mode 100644 .changeset/rollup-non-numeric-aggregand.md delete mode 100644 .changeset/runtime-gate-overlay-redefinition-universe.md delete mode 100644 .changeset/s3-adapter-key-namespace.md delete mode 100644 .changeset/sandbox-crash-outranks-declared-code-arm.md delete mode 100644 .changeset/schedule-trigger-acting-organization.md delete mode 100644 .changeset/scoped-packages-dispatcher-door.md delete mode 100644 .changeset/scoped-sdk-honours-metadata-prefix.md delete mode 100644 .changeset/sdui-parser-stageorder-funnel-only.md delete mode 100644 .changeset/security-fls-unknown-field.md delete mode 100644 .changeset/seed-locale-producer-wiring.md delete mode 100644 .changeset/serve-org-remedy-defers.md delete mode 100644 .changeset/single-posture-organization-census.md delete mode 100644 .changeset/solution-blueprint-module-header.md delete mode 100644 .changeset/spec-approval-continue-restored-contract.md delete mode 100644 .changeset/spec-cloud-provided-package-version.md delete mode 100644 .changeset/spec-cloud-subpath-retired.md delete mode 100644 .changeset/spec-functional-completeness-symbol-anchors.md delete mode 100644 .changeset/spicy-pears-count.md delete mode 100644 .changeset/spotty-jars-shave.md delete mode 100644 .changeset/standalone-plugin-scaffold-unscoped-private.md delete mode 100644 .changeset/standalone-stamp-comment-accuracy.md delete mode 100644 .changeset/strict-env-scope-roots-dyn.md delete mode 100644 .changeset/sys-user-role-prose-retired-action.md delete mode 100644 .changeset/temporal-text-operator-declared-type-gate.md delete mode 100644 .changeset/translate-flow-walks-adr-0031-regions.md delete mode 100644 .changeset/two-factor-verify-echoes-live-user-row.md delete mode 100644 .changeset/validate-refuses-blank-structural-condition.md delete mode 100644 .changeset/value-domain-note-settings-door-repointed.md delete mode 100644 .changeset/value-envelope-nullish-attribution.md delete mode 100644 .changeset/verify-in-process-handle.md delete mode 100644 .changeset/visiblewhen-app-scope-root-prose.md diff --git a/.changeset/15052-search-fields-docblock-icontains.md b/.changeset/15052-search-fields-docblock-icontains.md deleted file mode 100644 index c68553cba1..0000000000 --- a/.changeset/15052-search-fields-docblock-icontains.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits - -The docblock's ENGINE bullet claimed `@objectstack/objectql`'s -`expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** -clauses. It has compiled to `$icontains` since objectstack#7641: -`packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The -case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is -contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of -`fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. - -**Why the distinction is worth a clause rather than a word swap.** `$contains` -is contractually case-SENSITIVE, so a reader who trusted the old sentence built -an ingress gate, a test or a driver **stricter** than the platform is — a false -refusal, not a leak. The corrected bullet now says that in one clause, so the -next reader of this module does not have to reconstruct it from two other -packages. - -⛔ No behaviour changes. This is a module docblock; the engine has been right -since #7641 and no accept set, authorable key or published behaviour moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and -this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — -measured on the built artifact, with the old spelling absent from all 216 built -files afterwards and the docblock's own neighbouring sentence present at 2 as -the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not -shipped as source; the emitted declarations are the whole of its published -reach, and they change. - -The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` -`findData` refusing a `$searchFields` override the resolved set does not admit -(#4254) — was measured on the same tip and is unchanged: `findData` still calls -`assertSearchFieldsAreSearchable`, which resolves through this module's own -`resolveSearchFieldResolution` rather than re-implementing the rule. diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md deleted file mode 100644 index 0148f5ef59..0000000000 --- a/.changeset/15110-retired-element-node-refusal.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) - -Two halves of one vocabulary defect, and only one of them is a narrowing. - -**BREAKING** — a bare `element:filter` / `element:form` component node no longer -parses. Both elements were retired whole at element grain (ADR-0049 -enforce-or-remove): no renderer for either ever shipped in objectui, framework -or cloud. Every authorable key became a `retiredKey` tombstone at the time, but -the node itself kept parsing, and each schema's own docblock recorded that as a -limitation rather than an intention: - -> A bare node with empty `properties` parses clean (the open `type` union -> accepts any string, so a node-level refusal is not expressible here) - -It is expressible one level up. Both names join -`RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with -a located prescription — the same door already built for `user:profile`. - -``` -FROM PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: true } // nothing renders it; the console - // drew the unknown-type panel - -TO PageComponentSchema.safeParse({ type: 'element:filter' }) - -> { success: false, - issues: [{ code: 'custom', path: ['type'], - params: { retiredComponentType: 'element:filter' }, - message: '`element:filter` was removed in @objectstack/spec 17 …' }] } -``` - -**The prescription is not new prose.** Each node message is the element-grain -TAIL of that element's own `retiredKey` tombstones with the `property ` -clause dropped, so the node door and the props door carry one text — pinned -byte-for-byte in `component.test.ts`. An author who writes `element:filter` is -told to delete the component and use a view's `userFilters` quick-filter bar or -the list toolbar's filter builder; an author who writes `element:form` is sent -to the object-bound `object-form` block. - -**What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one -would demote a loud retirement to a silent skip on every reader that dispatches -on it — so both rows keep refusing each retired key with its own per-key -prescription, and `isKnownComponentType` still answers `true` for both. The open -string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and -every live `element:*` member parse exactly as before. The two D2 conversions -still strip the keys and still leave the node; what changes is that the node -they leave is now refused by name instead of sitting inert, and their prose says -so. - -**The other half is a plain bug fix, no accept set involved.** -`KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the -`component-type-unknown` authoring rule — was derived from every known type, -retired ones included. Measured through the rule: - -``` -FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." -TO type: 'element:fitler' -> hint: "Use a declared component type from the standard - vocabulary, or … give it its own namespace …" -``` - -The tool was renaming an author INTO a retired element — a rename the parser -refuses. The pool is now the known set minus whatever the vocabulary retired, -derived from the retirement map rather than restated beside it, so a type -retired tomorrow leaves the pool the day it lands. Live spellings are -unaffected: `global:serch` still proposes `global:search`, `record:detials` -still proposes `record:details`, `element:butotn` still proposes -`element:button`. - -Also corrected: the vocabulary docblock described the `ComponentPropsMap` row -set as a superset of the enum by "exactly" the string-arm registrations plus the -two tombstoned elements — one member short since `user:profile` joined it. - - diff --git a/.changeset/15117-action-engine-delete-id-array.md b/.changeset/15117-action-engine-delete-id-array.md deleted file mode 100644 index 1e504d9e4e..0000000000 --- a/.changeset/15117-action-engine-delete-id-array.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) - -`delete(object, id: string)` declared one id. The runtime facade -(`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` -all along — normalising the argument and issuing one `ql.delete` per id — and -described that in a comment as a tolerance two handler suites happened to cause. -The declaration was simply behind the behaviour, and the one first-party suite on -the array form could only reach it by hand-rolling a private copy of the -interface (a copy that had already drifted on `find`). - -The slot is now `delete(object: string, idOrIds: string | string[])`, and the -member's doc comment states the contract instead of leaving it to be inferred -from a runtime comment two packages away: - -- **Both spellings are contract.** One row is `delete(object, id)`; a set is - `delete(object, ids)` — a handler holding a list does not have to unroll it - into a loop to stay on the contract. -- **The array form is a convenience over the same per-row path** — not a bulk or - atomic delete. There is no transaction around the set: a failure part-way - leaves the ids before it deleted. An empty array deletes nothing and resolves. - -Nothing is removed and nothing narrows: every existing single-id call still -type-checks, and no runtime behaviour changes — this release makes the published -type describe what was already being served. That makes it non-breaking, not a -patch: widening a published parameter is a purely additive widening of a public -surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the -facade into a local context type to reach the array form can delete the copy and -annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. diff --git a/.changeset/15141-cluster-doc-pointer-site-urls.md b/.changeset/15141-cluster-doc-pointer-site-urls.md deleted file mode 100644 index a930ebd4c0..0000000000 --- a/.changeset/15141-cluster-doc-pointer-site-urls.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename - -Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no -longer in the tree — `apps/docs/redirects.mjs` has redirected -`/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was -folded in. The section numbers still resolved, so nothing was broken for a -reader following a link; what was broken is retrieval by filename, which finds -nothing. - -These two strings are the published half. `gen:docs` copies them into -`content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and -they also ship as JSON Schema `description` values under `packages/spec/json-schema/` -and as string literals in `packages/spec/dist/`. So the citation had to become -something a SITE reader can follow: - -``` -- See cluster-semantics.mdx §4. (a file that does not exist) -+ See /docs/kernel/cluster §4. (the address the redirect already resolves to) -``` - -⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the -tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a -reader who has the repo checked out. Copying that convention into a `.describe()` -would tell a docs-site reader to open a `content/docs/...` file they do not -have, which is the same class of unfollowable reference pointed the other way. -There is no in-repo precedent to copy either way: these are the only two -`.describe()` strings in `packages/spec/src` that cite a docs page at all. - -The site URL is also redirect-independent — it is the redirect's own target, so -the reference survives the redirect being retired. - -No accept set moves and no authorable key is added or removed: the schemas, -their parse behaviour and their exported types are byte-identical apart from -these two description strings. The two regenerated reference pages carry the -same one-line change on three rows. diff --git a/.changeset/15437-validation-messages-migration-route.md b/.changeset/15437-validation-messages-migration-route.md deleted file mode 100644 index 40e60349cb..0000000000 --- a/.changeset/15437-validation-messages-migration-route.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal - -`validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that -migrates it told an author to author the message on the rule -(`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, -#14253) that message has a translation route — -`objects.._validations..message`, resolved on the write -path — and the sibling prescription ten metres away in the same package -(`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door -returns) already names it. - -⛔ Nothing the old text said was false, and none of it is deleted. The defect is -**silence**: this is the *migration* text, read by exactly the population that -authored the retired key — the authors who wanted their rule messages -translated — and it steered them to a plain authored literal without mentioning -that the bundle key now exists. The literal advice stays; the route is added -after it. - -**Two texts in the file carried the narrow prescription, not one.** The -conversion's `summary` is the one the card named; the docblock above it asserted -that rule messages are *"not translated through a group"*, which would have sat -directly above the corrected summary. Both are completed. The docblock keeps its -17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, -including why the object-scoped group is not `validationMessages` returning (the -retired one was keyed by rule name at the top level, could not tell two objects' -rules apart, and had no reader). - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a -`.zod.ts`, so it is not shipped as source — but two published paths move, -measured on the built tree rather than reasoned about: - -- `dist` is in `files[]`, and the new sentence is emitted into six built files - (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, - `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the - same tree. An author running `os migrate meta --from 16` reads the changed - notice out of that runtime string. -- `spec-changes.json` is itself listed in `files[]`, and it carries the summary - twice. It is generated (`gen:spec-changes`), and `check:generated` caught it - stale — the conversion registry feeds two generated artifacts, not one. - -`docs/protocol-upgrade-guide.md` is the third, regenerated with -`gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are -regenerated, never hand-edited. - -⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its -fixture are untouched; no authorable key is added or removed. diff --git a/.changeset/16236-formula-return-type-measure-column.md b/.changeset/16236-formula-return-type-measure-column.md deleted file mode 100644 index f1430bdcdb..0000000000 --- a/.changeset/16236-formula-return-type-measure-column.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) - -**Behaviour change — read this if any dataset measure aggregates a `formula` -field.** `AnalyticsResult.fields[].type` for such a measure column was always -`number`, whatever the formula computes. It is now translated from the field's -declared `FieldSchema.returnType`: - -``` -FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"number"}, - {"name":"latest_due","type":"number"}]} - -TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], - "fields":[{"name":"first_label","type":"string"}, - {"name":"latest_due","type":"time"}]} -``` - -Both values were strings; both descriptors said `number`, so a renderer that -branches on the declared type never reached its textual or temporal branch. - -**The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the -authoring vocabulary (`number` / `text` / `boolean` / `date`); -`fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / -`time` / `geo`). Two of the four words do not exist on the wire at all: - -| declared `returnType` | `fields[].type` | -|:---|:---| -| `text` | `string` | -| `date` | `time` | -| `number` | unchanged — the producer's `number` is already correct | -| `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | - -**A formula with no `returnType` is unchanged.** The key is optional — "absent -when the type can't be proven (an ambiguous/`dyn` expression)" — and an -unproven formula's measure column keeps the `number` it had. The absence is not -read as an answer. That tier is written down as a row in `measureResultType`'s -own table rather than left as an implied code path, and so is the treatment of -a word outside the declared four: left alone, never guessed at. - -**For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s -`sourceFieldMeta` hook gains an optional fourth member on its return — -`returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a -host that returns the three-member shape still satisfies the contract and gets -exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the -key automatically, so a host on the plugin needs no change at all. diff --git a/.changeset/16274-initial-completion-history-guard.md b/.changeset/16274-initial-completion-history-guard.md deleted file mode 100644 index 874bf9769a..0000000000 --- a/.changeset/16274-initial-completion-history-guard.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. - -**The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). - -**What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: - -- the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. -- `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) - -On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. - -What changes: - -- **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. -- **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. diff --git a/.changeset/16746-connect-agent-account-nav.md b/.changeset/16746-connect-agent-account-nav.md deleted file mode 100644 index f0808a8d62..0000000000 --- a/.changeset/16746-connect-agent-account-nav.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Connect an Agent is reachable from the Account app, so a non-admin can mint their own key - -`POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the -Connect-an-Agent page says the key "acts as you". But the page's only navigation -entry sat in the Setup app, which declares `requiredPermissions: -['setup.access']` — so every non-admin following the shipped two-step guide, and -every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: -*"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and -`README.md`), stopped at step 1 while the endpoint behind the button had accepted -them all along. Measured before: a principal with no system permissions gets -`403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` -is absent from the wire. - -`CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` -entry, targeting the `account` app's `grp_account_developer` group beside the -`nav_account_api_keys` entry already shipping there. Measured after, over the -real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, -the real fold and the real RBAC-by-route filter): the same permissionless -principal gets `200` on `GET /api/v1/meta/apps/account` with -`grp_account_developer` carrying `['nav_account_api_keys', -'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still -answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. - -**Nothing else moves.** No backend change, no authorization change, no change to -which permissions exist, and the published "acts as you" promise is unchanged — -it simply becomes keepable for the users it was written for. The Setup entry -stays exactly as it was, so admins keep the page where the guide points, and no -gate is added or removed anywhere: a navigation contribution registers exactly -when the page registers, so an opted-out deployment -(`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. - -⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the -app-level `setup.access` gate fires before the group gate, so dropping the group -gate alone changes nothing, and dropping both serves 14+ unrelated Setup -surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to -every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an -`account.app.ts` entry: the `mcp` service registers unconditionally in `init()` -while this bundle registers behind `isMcpServerEnabled()`, so such an entry -would outlive its page and 404 for every signed-in user on an opted-out -deployment. - -Both entries deliberately share the item id `nav_connect_agent` — one -destination, one identity. That is scoped, not a collision: `SchemaRegistry` -keys contributions by target app and `applyNavContributions(app)` consults only -that app's bucket, so a nav item id is unique within one app's navigation tree, -and the translation bundles are keyed `apps..navigation.`. diff --git a/.changeset/17081-dev-admin-banner-says-what-it-sees.md b/.changeset/17081-dev-admin-banner-says-what-it-sees.md deleted file mode 100644 index 263d025f93..0000000000 --- a/.changeset/17081-dev-admin-banner-says-what-it-sees.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) - -`--seed-admin` (on by default in `os dev`) prints one credential, and it is the -**only** one a first-run operator is given. It is also, by construction, the -account with every *platform* capability and no *app-declared* one: its standing -is `admin_full_access`, whose `systemPermissions` are `setup.access`, -`studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` -and `manage_sharing` — all platform built-ins — plus the `'*'` -view-all/modify-all record bits. - -So in any app that gates its apps, tabs or nav entries on -`requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a -first-class platform feature the docs teach — the credential the terminal hands -over is the account that resolves to an **empty navigation**. A downstream -maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a -broken product. The app was correct. The banner had asserted a login and said -nothing about its audience, and it outranks whatever the app's own README says, -because it sits directly under the command that was just run. - -FROM → TO, on a boot that seeds: - -``` - 🔑 Dev admin: admin@objectos.ai / admin123 - seeded on empty DB · dev only — do not use in production -+ platform admin — Setup, Studio and every record, but NO app-declared capability, so -+ an app that gates navigation on requiredPermissions may show it an empty menu; grant -+ it a permission set under Setup → Users, or sign in as an account your app seeds -``` - -**Nothing about the seed changes.** What the first run creates — the account, -its address, its password, its promotion to platform admin — is a product-shape -decision and is untouched; only the banner's words move. The three lines print -only inside the branch that already prints the credential, so a boot that seeds -nothing is byte-identical to before. - -Dim continuation lines rather than a warning, deliberately: ADR-0115's -`OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set -because "a warning about a non-event spends the attention the real ones need". -That exclusion is kept — this qualifies an event that just happened, on the line -that already announces it, and adds no new line where there was none. - -The route the sentence names is asserted against the declarations that make it -reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what -this account holds, the `Users` entry is ungated, and the `sys_user` detail page -carries the "Grant permission set" related list. A rename on any of those reds -the pin instead of leaving the banner pointing at nothing. diff --git a/.changeset/17124-daterange-array-arm-arity.md b/.changeset/17124-daterange-array-arm-arity.md deleted file mode 100644 index 2732009bb3..0000000000 --- a/.changeset/17124-daterange-array-arm-arity.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) - -`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no -length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the -analytics faces through `POST /analytics/dataset/query`, which types its selection -from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that -read the arm answered it three different ways — measured over one authored -document and four rows: - -| face | `['2026-01-01']` meant | -|---|---| -| `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | -| `NativeSQLStrategy` | no time clause at all — the whole dataset | -| the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | -| `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | - -For a dashboard that is one day's number, the whole dataset's, and everything -from that day onward, from the same document, decided by which backend answered. -`[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached -`parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. - -One rule is now the single reading of the arm and all four faces call it; the -three divergent fallbacks are deleted. An array that is not exactly two string -bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 -envelope — the answer the contract already gives for a `dateRange` that does not -denote a window. A two-element window is untouched on every face, bound for -bound, including the inclusive upper reading a caller's bounds keep (#16179) and -the half-open bare-day widening on the SQL side (#3777). - -### Write both bounds - -| wrote | write instead | -|---|---| -| `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | - -That spelling already selects exactly that one day on every face, and it is the -same instruction #16322 shipped for the single-day string dialect. - -⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED -moves. The spec's own refusal wording already states that *"an explicit window is -the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and -#16322's shipped migration table already told authors to write a single day as -`['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid -document; it was an invalid one that four faces answered arbitrarily, and a -behaviour that was never one behaviour is not a behaviour this removes. The Zod -type admitting the shape is weaker than the contract the same file states — -tightening it is a separate, spec-owned question. diff --git a/.changeset/17166-object-grid-export-options-describe-members.md b/.changeset/17166-object-grid-export-options-describe-members.md deleted file mode 100644 index fa6e4ad06d..0000000000 --- a/.changeset/17166-object-grid-export-options-describe-members.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two - -The entry is `z.unknown()`, so nothing about this key is parsed, refused or -stripped: a member that does not exist draws no error and has no effect, and a -member that does exist cannot be discovered from the schema. That makes the -`.describe()` string the entire account of the key's shape rather than a summary -of an enforced one — and it projects straight into -`content/docs/references/ui/component.mdx`, which is what an author (or a -generating model, ADR-0033) reads. - -It named two members, `formats` and `streaming`. The only renderer reads five. - -Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` -— objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the -`schema.exportOptions` expression and the `exportConfig` local bound to it, with -objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose -comment/string stripping is what stops a prose mention of a key being counted as -a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, -`includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control -(`zzzNotAMember`) 0 on the same instrument — which is what makes those five -counts readings rather than a matcher that matches anything. The same instrument -answers the same five, with the same per-member counts, at objectui -`3fbdd4a2dae1`, so the set is not an artefact of the pin's age. - -The three missing members are `maxRecords`, `includeHeaders` and -`fileNamePrefix`. An author reading the old string learned that -`exportOptions` takes `{ formats, streaming }` and had no way to reach the other -three short of reading the renderer's source — the shape objectstack#8010 -closed for this same key one layer out, when `streaming` was read for releases -while no schema declared it. - -⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either -direction. Giving `exportOptions` a real shape is a separate and much larger -change with its own review requirements; this is the docs half only. - -The new list is not restated in prose that can drift on its own. A pin holds the -describe string's member enumeration equal to the members -`ListViewExportOptionsSchema` declares — the spec's own five-key declaration of -this same authoring block, reached through `ListViewSchema.exportOptions`'s -object branch and itself derived from that same read set. Both spellings reach -one renderer, so narrowing or widening the declared block now reds the -`z.unknown()` prose instead of leaving it quietly behind: the declared side has -parse failures to catch drift, this side had nothing. The pin also records that -the key is unvalidated today, so the day it grows an accept set is a deliberate -decision rather than a silent one. - -`content/docs/references/ui/component.mdx` is regenerated from the string -(`gen:schema` then `gen:docs`) and carries the same one-line change. diff --git a/.changeset/17175-non-raising-table-presence-probe.md b/.changeset/17175-non-raising-table-presence-probe.md deleted file mode 100644 index 4b5a313531..0000000000 --- a/.changeset/17175-non-raising-table-presence-probe.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) - -Two migrations on the boot hook asked "does this table exist?" with a statement -that cannot succeed when the answer is no — `SELECT "tenant_id" FROM -"_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT -1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read -the refusal as "no". Both are correct on their own terms. Both make -`SqlDriver.execute()`'s raw terminal write the statement and the dialect's -message to the operator's log on the way out. - -Measured on this tree against real `better-sqlite3`: exactly one line per probe, -on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token -and `no such table`. It fires on **every boot** of every install that has never -allocated an autonumber, and again on every boot of every kernel that does not -register the optional `service-settings`. - -⭐ The cost is not the line. It is that operators learn this product prints -errors when nothing is wrong, and then miss the one that matters. A consumer told -to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` -as a failing boot) must either ignore an unactionable ERROR every boot or chase a -platform-internal probe. - -**The question is now asked of the CATALOG.** A new shared -`migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` -for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with -`DATABASE()` for MySQL — each of which returns zero rows for a table that is not -there instead of being refused. Both migrations call it; the probe lives once, -not once per site. - -**⛔ Why not in the driver.** Quietening a refusal requires classifying it, this -repo has one predicate for that (`isMissingTableError`), and it needs the name of -the thing the caller was reading — which the raw path structurally does not have -(`rawStatementFaultError` declares no targeted table, and -`driver-error-classification.callers.test.ts` fails any in-repo call that omits -`readObject`). An unclassified demotion of the driver's raw terminal would -quieten real failures too. The caller knows the table; the driver does not. - -**⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm -mis-compiled for some dialect would be refused, caught by the same `catch` the -expected miss uses, and read as "the table is not there" — turning a stored-row -data repair into a silent no-op on whichever dialect nobody exercised. So the -probe answers four verdicts rather than a boolean, and `'unreadable'` is never -folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised -dialect gets no guessed catalog statement at all — it keeps the caller's own -`WHERE 1 = 0` probe, whose refusal is now *classified* with -`isMissingTableError(error, table)` rather than swallowed as absence. - -**Why `minor`.** - -- `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no - input a caller writes is affected; the one consumer shape that could break is - an exhaustive `switch` with a `never` default, which is why this is not a - `patch`. -- `ensureSysSettingIdentityIndex` gains an optional third parameter - (`{ client? }`). Callers that pass two arguments are unchanged and keep - today's behaviour exactly — without a client there is no catalog arm and the - pre-existing probe runs. -- `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in - text and still exported. They are no longer what the boot path runs first. -- `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are - re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and - every importer see no difference. - -**What did NOT change.** #10789's ruling stands: a seam that accepts a statement -and returns no result set still reports `absent` with the `detail` that separates -it. The driver's error channel is untouched — a statement the backend genuinely -refuses is still written to the log in full, asserted against the same driver and -the same sink in the same test as the silence. - -**Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to -end against a real `SqlDriver` (`packages/runtime`'s -`seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against -the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions -and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT -MEASURED against a live server: this package has no live-PG harness, no `pg` -dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to -`live-mysql`. Its statement text is pinned; running it is not. diff --git a/.changeset/17177-seed-summary-declares-its-scope.md b/.changeset/17177-seed-summary-declares-its-scope.md deleted file mode 100644 index ba396c90f3..0000000000 --- a/.changeset/17177-seed-summary-declares-its-scope.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe - -`SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every -plugin before it fires `kernel:ready` — where the first-admin handoff -(`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. -That handoff is the designed completion of a NULL owner column, so two of the loader's -pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and -`Deferred reference back-fill FAILED` — were making a bare present-tense claim -(`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the -log or the table to tell an operator that the other reading existed. - -Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the -boot step that can supersede them and stating that a non-NULL value found later is not -evidence the reference resolved. Level, error count and remedy are unchanged — this is a -scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are -deliberately untouched: they report a row that never landed, so no later boot step can -write a column of it and their claim survives to the end of boot as written. - -Nothing an author writes changes. Anything that greps the loader's output for the literal -`stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. diff --git a/.changeset/17265-nested-hook-refusal-is-a-rejection.md b/.changeset/17265-nested-hook-refusal-is-a-rejection.md deleted file mode 100644 index 777560ecd9..0000000000 --- a/.changeset/17265-nested-hook-refusal-is-a-rejection.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` - -`POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a -`beforeUpdate` hook refused a state transition for a business reason and the -refusal travelled out through the action body's `ctx.api` write. The same refusal -has answered **`400`**, with the hook's sentence verbatim, on `/data` since -objectstack#11588. A 500 tells every client "the platform broke", so a -well-behaved one retries, alerts or pages for a guard that will never say yes. - -**Where the producer was.** Not in the action route's classifier — that read the -shape it was handed correctly, and both sides of the line it pins (`a deliberate -REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal -arrived already stripped of every mark that says "a body reported this on -purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` -crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) -on an `instanceof` test — and a nested sandboxed hook's refusal *is* a -`SandboxError`, wrapped by the same runner one level down. The pump branch that -reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, -and the classifier read the missing business message as a crash. - -**What changed.** The marker now asks the question the `/data` door asks — -`sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's -class. Both of that predicate's conditions travel, because both are load-bearing: -a capability denial carries no business message and stays a fault, and a nested -body that **crashed** carries `TypeError: …` and stays a fault too. - -**No status was picked for this route.** It matches what `/data` already answers -for the same producer: the status the body declared, or `400` when it declared -none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now -reaches the caller as `409 RECORD_LOCKED` instead of losing both. - -**The sentence a caller receives is byte-identical to what the 500 carried** — -this moves the status, not the prose. The flattened `SandboxError: ` name prefix -is stripped on the rejection path by the same helper the fault path already used. - -No authorable key, accept set or export surface moves; no consumer needs a -change. Clients branching on 5xx to decide whether to retry will stop retrying -these refusals. diff --git a/.changeset/17328-colspan-rule-withdrawn.md b/.changeset/17328-colspan-rule-withdrawn.md deleted file mode 100644 index 7647e1cdc6..0000000000 --- a/.changeset/17328-colspan-rule-withdrawn.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) - - - -**BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and -`validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A -TypeScript consumer that imported that constant (to suppress the rule, or to route it) -stops compiling on the import, and the compiler names the site — a more precise channel -than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged -and still valid. - -The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a -rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 -/ page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real -authored 3-column section at all three of the widths that sentence names (390 / 720 / -1700), that misalignment does not happen. The renderer emits one container-query-scoped -span class clamped to the section's declared column count, so the cell starts at a real -column boundary at every width and rendered overflow is 0px in every configuration — -including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp -did not work. The clamp is precisely why the claim was false, and the rule's own file -already recorded the clamp a few lines above the claim. - -The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to -the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule -warned about one spelling and recommended the other, and they are the same thing. At the -modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an -author who complied was left worse off than one who ignored it. - -With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so -the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a -`colSpan` within the column count renders exactly as authored, and one above it clamps. -Every test that pinned the rule's wording or its firing set was re-judged in place with -the reason recorded, never deleted, and each re-judged pin is paired with a live finding -on the same fixture so that a walk which stopped reaching the site could not pass as a -withdrawal. diff --git a/.changeset/17333-date-macros-header-adr-0053.md b/.changeset/17333-date-macros-header-adr-0053.md deleted file mode 100644 index 741f288f46..0000000000 --- a/.changeset/17333-date-macros-header-adr-0053.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced - -The header's "Out of scope" block told an author that on a `datetime` column -`<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the -half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. -The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used -as an upper bound denotes the WHOLE day, compiled half-open to the next -calendar day. It is stated once, in -`packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator -table reads: - -| Operator | A bare `YYYY-MM-DD` on a `datetime` column means | -|---|---| -| `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | -| `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | - -and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: -the case *"datetime: bare-day `$lte` keeps the whole final day"* expects -`d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. - -**Why this header and not a note.** It is the doc comment on the vocabulary an -AI author reaches for, and it is the one place in the tree that says what a -`*_end` token does on the right-hand side of an operator. Both the old -prescription and the correct spelling parse, run and return rows, so nothing -downstream reports the mismatch — the author simply carries the wrong model -into every later filter. - -**What the correction does.** The load-bearing first clause is kept verbatim: a -`*_end` token IS the period's last calendar DAY. What follows now **cites** -`calendar-day.ts` rather than restating the rule, so the two statements cannot -drift apart again, and the half-open detour is refused by name for the reason -it is now wrong — the widening is already applied. - -⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, -authorable key or published payload moves. - -**This is shipped, which is why it carries a changeset rather than -`skip-changeset`.** `@objectstack/spec`'s published `files[]` lists -`src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the -first thing in it. - -The generated reference page `content/docs/references/data/date-macros.mdx` -carried the same sentence — it is rendered from this header and is marked -AUTO-GENERATED — and is regenerated here with -`pnpm --filter @objectstack/spec gen:schema && … gen:docs`. diff --git a/.changeset/17343-multi-valued-boolean-contains-membership.md b/.changeset/17343-multi-valued-boolean-contains-membership.md deleted file mode 100644 index b4953d65f3..0000000000 --- a/.changeset/17343-multi-valued-boolean-contains-membership.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean column keeps its `$contains` membership filter - -A `multiple: true` field is stored as a JSON TEXT array, and on such a column -`$contains` is not a substring test — it is the MEMBERSHIP spelling, the one -operator #7398 left working there after refusing the equality family. The -declared-type gate added in #14079 fired on the boolean limb regardless of -storage shape, so a membership filter over a `multiple: true` `boolean` or -`toggle` column compiled to the always-false constant: - -``` -{ flags: { $contains: 'true' } } -- select * from `probe_tbl` where 1 = 0 (matched nothing) -+ select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) -``` - -That is the fail-CLOSED direction: the query returns a `200` with no rows, -byte-identical to a filter that legitimately matched nothing, so an author sees -"no matching records" and doubts their data rather than the filter. Both -registry fills — `initObjects` and `registerExternalObject` — were affected, and -both are fixed, because the repair is at the predicate they share. - -The same shape on a `multiple: true` NUMBER was already correct (its registry is -filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the -temporal limb at the predicate. This change spells it on the boolean limb, the -one that had neither. `booleanFields` itself is deliberately unchanged: it is a -read-coercion registry, and the three other seams that read it — the Postgres -aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are -about "this column holds a boolean", which a multi-valued column still does. - -⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still -answers the declared no-match for every positive text operator and `$notContains` -its exact complement, unchanged. What moves is exactly the JSON-column cell. diff --git a/.changeset/17416-packages-get-version-scope.md b/.changeset/17416-packages-get-version-scope.md deleted file mode 100644 index 627bd47da4..0000000000 --- a/.changeset/17416-packages-get-version-scope.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) - -The route accepted a `?version=` query parameter and the only surface serving it -never read the parameter. A caller asking for a version that is not installed -was answered `200` with the **installed** row, and nothing in the status, -headers or body distinguished that from a version-scoped read that actually -happened. - -The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` -(`@objectstack/client`) declares `version?: string` and appends it, so the SDK -has been sending a parameter the runtime dropped. The handler that honoured it -— the REST registrar's twin of this route — was removed with the duplicate -response shape, and the dispatcher's `/packages` domain never had that read to -inherit. - -``` -FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) - -> 200 { data: { manifest: { version: "1.0.0" }, … } } - -TO GET /api/v1/packages/com.acme.crm?version=99.0.0 - -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not - found — installed version is '1.0.0'" } } -``` - -**What does not change.** The unversioned read is untouched, down to the row and -the writability verdict it stamps — pinned as the lit control beside the new -assertions, because a green on only the scoped path would also pass with the -ordinary read broken. `?version=` naming the installed version is served -exactly as the unversioned read is, and so is `?version=latest`: the deleted -handler read `requested.value || 'latest'` and its store resolved `latest` to -the newest row, so "no version" and "`latest`" named one request there and name -one request here. An id the registry does not hold keeps its existing 404 -wording whether or not `?version=` rode along — a package that is not installed -cannot be at the wrong version. - -**This is request-side only.** The response shape is not touched, so the route -still answers with exactly one body shape; comparison is exact string equality -on the version, the same predicate the durable package store uses (`AND version -= ?`), so the two answers to "is this package at version v" cannot drift into -semver-range semantics at one of them. - -A repeated `?version=a&version=b` is no longer resolved by silently choosing -one — it is answered with a refusal naming what was seen. The repo's one rule -for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is -the right end state for this door too; it is not restated here, because the -helper that owns that rule and its message is not exported from -`@objectstack/rest`. diff --git a/.changeset/17511-i18n-extract-region-screens.md b/.changeset/17511-i18n-extract-region-screens.md deleted file mode 100644 index add4430664..0000000000 --- a/.changeset/17511-i18n-extract-region-screens.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region - -`walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated -`flow.nodes` flat, so a `type: 'screen'` node inside a region — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It -emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry -and **no** coverage row. - -**Why that pairing is the defect and not just a missing translation.** A nested -wizard step is a real screen: the executor pauses on it and the client receives -its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the -key is live. With no entry emitted, a translator was never shown the key AND -`os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was -invisible to the mechanism built to report gaps. A green i18n gate on a tree -whose nested steps render source-locale text was green because the surface was -unreachable, not because the app was translated. - -The node universe now comes from a region-aware descent that reads the one -shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from -`@objectstack/spec/automation` — the same table `packages/lint`'s -`walkFlowNodes` reads. No local copy of the slot list is introduced: a second -region table in a fourth package is the very shape this defect is an instance -of. - -**Depth deliberately does not enter the key.** Entries stay -`flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` -is keyed by node id alone and the bundle schema knows nothing about depth; a -region path segment would offer a key nothing resolves. A node id repeated at -two depths therefore addresses one bundle slot and collapses to a single entry -(first emission wins, outer before inner) — one slot can serve only one string, -and the resolver overlays that string onto both nodes. - -Seeding is unchanged and applies at every depth: a screen `title` falls back to -the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back -to its `name` as a *derived* seed, so the skeleton stays usable while the -coverage gate demands no translation of a string nobody authored. - -⛔ No authorable key, bundle shape or export moves — an author who wrote a -nested screen now gets scaffolding and a coverage row where both were silently -absent. Existing keys are byte-unchanged. diff --git a/.changeset/17562-initial-failure-history-guard.md b/.changeset/17562-initial-failure-history-guard.md deleted file mode 100644 index bd20f4bcdd..0000000000 --- a/.changeset/17562-initial-failure-history-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) - -`AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. - -**What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. - -Reproduced with a control, the identical flow and the identical node failure differing only in the store: - -``` -store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} -store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} -``` - -**What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. - -What changes: - -- **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. -- **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. -- **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. - -⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. diff --git a/.changeset/17579-approver-type-manager-describe.md b/.changeset/17579-approver-type-manager-describe.md deleted file mode 100644 index d4e9fb9879..0000000000 --- a/.changeset/17579-approver-type-manager-describe.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value - -`ApproverType` carried **no** `.describe()` at all, so the generated reference -page rendered `## ApproverType` with nothing but an `### Allowed Values` list: -`manager` — the one rung an author cannot operate on a stock install — read -exactly like the nine members that work. `{ type: 'manager' }` resolves -`sys_user.manager_id`, and that column still has no product write surface -(re-measured on this tree: the identity write guard's managed-update whitelist -for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; -no `packages/plugins/plugin-auth` source writes it). An author who chose it got -a chain that passed `validate` and `lint` and then stalled on its first -submission. - -The new describe says what is true about `manager` and **points** at the remedy -rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in -`packages/lint/src/validate-approval-approvers.ts` remain the single -authoritative copy of the population routes, and that file's `DEPENDENCY` -docblock now names this new string among the lines that go stale if the column -ever gains a write surface. A pointer cannot drift into disagreement with what -it points at, which is why no third copy of the 667-character remedy was added. - -⛔ No member is added, removed or renamed, and no behaviour changes: the enum's -accept set is byte-identical and `check:api-surface` is green on the rebuilt -`dist/*.d.ts`. - -**Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` -carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is -measured in all three on the built tree — `dist/automation/index.js` and -`.mjs` (2 files, against a lit control of an existing describe from the same -module, also 2), four `json-schema/` documents (`ApproverType.json`, -`ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and -the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ -`patch`. - -The `packages/lint` half is a docblock comment and is deliberately **not** -graded: that package publishes `dist` only, and the new sentence is absent from -it (0 files) while a runtime string from the same source file is present in 4 -and a pre-existing comment from the same docblock is absent in 0 — so comments -are stripped by construction and nothing published moves there. diff --git a/.changeset/17586-multi-valued-boolean-read-inversion.md b/.changeset/17586-multi-valued-boolean-read-inversion.md deleted file mode 100644 index 224c0e3fb2..0000000000 --- a/.changeset/17586-multi-valued-boolean-read-inversion.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` - -`formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell -into a real array, and then its `booleanFields` pass did -`data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a -`multiple: true` `boolean`/`toggle` column presented a single `true` whatever -the array held — a stored `[false]` read back as **`true`**, the opposite of -what is stored, with no error anywhere. `readPresentationKind` hands the same -presenter to the `aggregate()` / `distinct()` doors, so the collapse was not -confined to the row-read door. - -**Fixed at the registry fill.** `&& !field.multiple` is the condition the three -neighbouring pushes in both registration blocks already carry (`mediaCols`, -`numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single -omission, in **both** fills (`registerExternalObject` and -`registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON -column here, and its array is written faithfully — only the read collapsed it. - -**What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a -`multiple: true` `boolean` or `toggle` column now returns the stored array of JS -booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code -that consumed the old scalar was reading a value that did not reflect storage — -including for an all-`false` array. Scalar `boolean`/`toggle` columns are -unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the -`multiple: true` number and `tags` classes were already correct and do not move. diff --git a/.changeset/17610-notification-dispatcher-idle-cost.md b/.changeset/17610-notification-dispatcher-idle-cost.md deleted file mode 100644 index 90466f3bb0..0000000000 --- a/.changeset/17610-notification-dispatcher-idle-cost.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) - -**What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. -- **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. - -**Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. diff --git a/.changeset/17623-http-dispatcher-idle-cost.md b/.changeset/17623-http-dispatcher-idle-cost.md deleted file mode 100644 index 08d3d5a130..0000000000 --- a/.changeset/17623-http-dispatcher-idle-cost.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) - -**What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. - -**Now:** - -- **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. -- **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. -- **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. -- **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. - -**Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): - -- a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; -- a row enqueued by a process that does not run this dispatcher; -- a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). - -Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. - -**Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. - -**One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. diff --git a/.changeset/17634-http-ack-claim-credential.md b/.changeset/17634-http-ack-claim-credential.md deleted file mode 100644 index 738a2744bc..0000000000 --- a/.changeset/17634-http-ack-claim-credential.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -`IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. - -Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. diff --git a/.changeset/7898-auth-gate-fail-close.md b/.changeset/7898-auth-gate-fail-close.md deleted file mode 100644 index 093df3dda5..0000000000 --- a/.changeset/7898-auth-gate-fail-close.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) - -`isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" -as allow-listed. That is a fail-OPEN default on an authorization seam: any -caller that reached the ADR-0069 gate with an absent or empty `path` was exempt -on **every** route, and a transport author who simply forgot to populate `path` -disabled the gate with no diagnostic of any kind. - -``` -FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route - isAuthGateAllowlisted('') -> true - -TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned - isAuthGateAllowlisted('') -> false -``` - -Exemption is now something a path has to EARN by naming an allow-listed route, -so the failure mode of omission is a `403` rather than a bypass. The predicate -is split in two so it carries exactly one meaning: a private -`matchesAllowlistedRoute` answers the route question for a real, non-empty path -— its body is unchanged, the #16839 anchoring rules included — and the exported -predicate answers "is this request exempt", which a request with no path is not. - -**No current caller's behaviour moves.** The caller census was re-run: the same -four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, -`shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach -the predicate with a real string; a corpus differential against the pre-flip -predicate over more than 10,000 paths moves exactly one input — the empty string -— and nothing else, in either direction. - -**The one exemption that remains for a genuinely pathless caller is explicit**, -and lives at the one seam that really routes by body: `shouldDenyAnonymous` -declares `path` optional and decides the no-path case itself (it denies), ahead -of this predicate. That guard is deliberately kept rather than collapsed into -the now-agreeing default — a seam's contract should not be re-derived from what -a predicate happens to do with a falsy argument. - -**Known follow-up, tracked as #17625.** The dispatcher's bare-root -`` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is -stripped), which was exempt via the fail-open default and is not exempt now, so -a *gated* session — one carrying an `authGate`, i.e. an expired password or a -required MFA enrollment — reaching the bare root gets a `403` instead of the -discovery payload. Every named remediation route (`/auth/*`, `/health`, -`/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so -remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 -of the same ruling and is **not** a tolerance re-added here. diff --git a/.changeset/action-confirmation-gate-enforced.md b/.changeset/action-confirmation-gate-enforced.md deleted file mode 100644 index 1b1e44cb00..0000000000 --- a/.changeset/action-confirmation-gate-enforced.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/mcp": minor ---- - -fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) - -**Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. - -FROM → TO, for a caller of a gated action: - -``` -run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran -run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required -``` - -The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. - -**What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: - -- Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. -- Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. -- Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. -- `list_actions` is unchanged. - -**A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. - -Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). diff --git a/.changeset/admin-create-user-reads-membership-policy.md b/.changeset/admin-create-user-reads-membership-policy.md deleted file mode 100644 index 61414b1a24..0000000000 --- a/.changeset/admin-create-user-reads-membership-policy.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) - -**BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. - -ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. - -FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. - -TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. - -Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. - -If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. - -The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. - - diff --git a/.changeset/adr-0112-envelope-refusal-declaration.md b/.changeset/adr-0112-envelope-refusal-declaration.md deleted file mode 100644 index 01b968d9f2..0000000000 --- a/.changeset/adr-0112-envelope-refusal-declaration.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) - -`ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. - -The three cases are now documented side by side on the envelope's TSDoc: - -- **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. -- **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. -- **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. - -Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". - -This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. diff --git a/.changeset/analytics-compareto-kind-refusal.md b/.changeset/analytics-compareto-kind-refusal.md deleted file mode 100644 index a47684f9ae..0000000000 --- a/.changeset/analytics-compareto-kind-refusal.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) - -`shiftRange` had one branch and a fall-through — `previousYear` was named, and -**everything else** landed in the `previousPeriod` arm. No `default`, no -exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a -previous-period comparison under an ordinary **200**, and the caller was told -nothing. The wrong answer is a comparison **window**: a number a dashboard -renders and a person reads as fact, with no status, header or field in the -response to distinguish it from a real answer. - -`DatasetCompareTo.kind` has only ever declared two values -(`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript -interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door -parses only the seven members the selection shares with `AnalyticsQuery` — -`compareTo` is one of the four it projects away before its parse, and the route -forwards the caller's selection to the service untouched. So `kind` was checked -by `tsc` inside this repo and by nothing at all on the wire. - -## FROM → TO - -| Input | Was | Now | -|:--|:--|:--| -| `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | -| `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | -| `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | - -The fix is to name one of the two declared windows, or drop `compareTo` — which -is what the refusal says. No accept set widens, no new error code is minted: the -refusal is the fourth member of the `datasetInvalidError` family -`resolveCompareDimension` already raises three times for the same document, so it -arrives at the route through the envelope that route already classifies on. - -## Why this is a `patch` - -It pulls behaviour back onto the contract the type has always declared, rather -than narrowing past it: every input `DatasetCompareTo` permits returns -byte-identical windows, pinned by a control in the same change. What flips from -200 to 400 is input the declared contract never permitted. The reachable-today -population for that input was measured on the tree — the dashboard authoring path -is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a -`z.enum`, so a third kind cannot arrive through a parsed widget), and no producer -in this repository sends a third value. What is not enumerable from here is a -consumer outside it calling the published `shiftRange` export, or posting a -hand-rolled body to the dataset route; for those, the refusal replaces a wrong -answer with a located one. - -`alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains -no refusal of its own: it is not on the package's public surface, and its only -caller runs `shiftRange` first — both pinned, so exporting it turns the pin red -rather than silently reopening this defect. diff --git a/.changeset/analytics-dataset-query-selection-door-parse.md b/.changeset/analytics-dataset-query-selection-door-parse.md deleted file mode 100644 index afd9fb60f5..0000000000 --- a/.changeset/analytics-dataset-query-selection-door-parse.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -`POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do - -The route checked one thing about the body it forwards — that -`selection.measures` was a non-empty array — and forwarded everything else -unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at -the entry and lift a malformed member to a 400 before the service is reached, -so a client met two postures on one family depending on which door it knocked -on, and a malformed member of `selection` travelled into `dataset-executor` to -be answered by whatever the face behind it happened to do with it. - -⚠️ **A 400 is newly reachable.** Requests that previously slipped through are -now refused. Two shapes: - -- A `timeDimensions[].dateRange` outside the closed preset vocabulary answers - `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording - the sibling door has answered for the identical condition since the - vocabulary closed. Measured on the tree before this change, the literal - string `not a range at all` reached the executor under an ordinary `200`. -- Anything else malformed answers `400 VALIDATION_FAILED` with - `details.fields[]`, each entry naming the member as `selection.`. - -**What is NOT newly refused, deliberately.** `selection` is a -`DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it -carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and -`totals` are members of its own. Reusing the sibling schema would have refused -every real dashboard widget. What the door parses is the projection of the -seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` -member of the same name — `dimensions`, `measures`, `timeDimensions` (declared -there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal -set is exactly what the published interface already declared. The four -dataset-only members are projected away before the parse and keep reaching the -executor untouched. - -Validation-only: the caller's `selection` object is what `queryDataset` -receives, by identity, never a parse output. diff --git a/.changeset/analytics-daterange-driver-alignment.md b/.changeset/analytics-daterange-driver-alignment.md deleted file mode 100644 index 576241cbf8..0000000000 --- a/.changeset/analytics-daterange-driver-alignment.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/driver-memory": minor -"@objectstack/service-analytics": minor -"@objectstack/spec": patch ---- - -fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) - - - -**BREAKING** for an in-process caller that reaches an analytics face PAST the -schema door with a string the closed vocabulary does not contain: it used to be -answered, and is now refused. Shipped as `minor` under the repo's launch-window -convention. The driver half of #16041, whose spec change closed -`AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen -dashboard preset names; every value affected here was already refused at -`POST /analytics/query` and `/analytics/sql` when that landed. - -## What was wrong - -#16041 closed the contract; the faces behind it never aligned, so the defect it -abolished simply moved onto the newly-blessed vocabulary. Measured on the built -`driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, -2099): - -| input | before | after | -|:--|--:|--:| -| `today` | 1/5 | 1/5 | -| the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | -| `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | - -`driver-memory` recognised exactly `today`: every snake_case preset missed its -`startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose -two bounds were the preset's own NAME, which matched every `Date`-typed row -under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered -the same names — and unrecognised strings, and `today` — to the point window -`created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is -whatever the dialect decides a vocabulary word compares as. So a dashboard -asking for one month got all of history on one backend and a nonsense -comparison on the other, at HTTP 200 on both. - -## What it does now - -- **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / - `resolveAnalyticsDateRangeString` resolve every declared preset to - `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens - handed to the existing macro resolver, so `dateRange: 'this_month'` and a - `{month_start}` filter token cannot answer differently, and the anchoring on - `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) - come from that resolver rather than from each face. -- **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 - envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own - `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door - answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call - it, so "memory and SQL refuse identically" is one function rather than an - agreement. -- **The upper bound keeps #16179's separation.** A window a face RESOLVED is - compared exclusively (`$lt` / `<`) for the ten calendar presets and - inclusively for the three rolling `last_N_days`, whose bound is NOW; an - explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. -- The fifteen `driver-memory` date-range pins #16041 retired are reinstated in - preset form (DST cells re-measured under calendar semantics, not re-spelled), - and one cross-face conformance fixture holds all FOUR faces to the same - windows and the same refusal. -- **The draft-preview evaluator is the fourth face**, and it is in that fixture - for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — - the Live Canvas preview over a pending seed draft) carried the identical - `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, - silently, while the published chart beside it answered a real window — across - a publish boundary the preview exists to make continuous, since publish - materialises the same seed. - -## FROM → TO - -Unchanged from #16041's — the spelling that is refused here is the spelling that -was already refused at the door. - -| you wrote | write instead | -|:--|:--| -| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | -| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | -| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | -| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | - -The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's -code stays registered under `@objectstack/runtime` (the door that names the wire -vocabulary), and the waiver records that the shared constructor spelling it -lives one package over. diff --git a/.changeset/analytics-inline-dataset-object-read-admission.md b/.changeset/analytics-inline-dataset-object-read-admission.md deleted file mode 100644 index 880074199e..0000000000 --- a/.changeset/analytics-inline-dataset-object-read-admission.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": minor -"@objectstack/service-analytics": minor -"@objectstack/verify": minor ---- - -fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on a published -route — landing in the launch window as `minor` on all four packages (the -lockstep convention: during the window the bump level is not the carrier, this -banner and the disposition above are). Nothing that was already admitted -becomes refused **except** the requests `GET /data/` refuses today for -the same principal, which is the defect. Nothing that was refused becomes -admitted. - -`POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. - -The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. - -**This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. - -- **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. -- **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. -- **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. -- **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. - -The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. diff --git a/.changeset/analytics-reference-dimension-display-labels.md b/.changeset/analytics-reference-dimension-display-labels.md deleted file mode 100644 index 56fff05f31..0000000000 --- a/.changeset/analytics-reference-dimension-display-labels.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. - -`packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: - -``` -Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } -Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } -``` - -- **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. -- **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. -- **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. -- **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. - -No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. diff --git a/.changeset/analytics-row-scope-bridge-three-way.md b/.changeset/analytics-row-scope-bridge-three-way.md deleted file mode 100644 index 7fcf9b4e83..0000000000 --- a/.changeset/analytics-row-scope-bridge-three-way.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) - -`AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: - -```ts -const trySecurity = () => { - try { - const svc = ctx.getService('security'); - return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; - } catch { return undefined; } -}; -getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); -``` - -A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. - -**What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: - -- **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. -- **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. - -**This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. - -**No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. - -⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. diff --git a/.changeset/analytics-row-scope-refusal-envelope.md b/.changeset/analytics-row-scope-refusal-envelope.md deleted file mode 100644 index 0a5439bf46..0000000000 --- a/.changeset/analytics-row-scope-refusal-envelope.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) - -`queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. - -Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. - -Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: - -- the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; -- its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. - -Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. diff --git a/.changeset/analytics-sqldialect-declared-vocabulary.md b/.changeset/analytics-sqldialect-declared-vocabulary.md deleted file mode 100644 index 4911b281a3..0000000000 --- a/.changeset/analytics-sqldialect-declared-vocabulary.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) - - - -**BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning -`string`: the hook's declared return is now the three canonical dialect names or -`undefined`, so such a composition stops compiling until the host's own annotation -says which names it can answer. Shipped as `minor` under the repo's launch-window -convention, in which breaking-ness is carried by this banner and the disposition -above rather than by the bump level. Runtime behaviour for every host is unchanged: -the same three names were the only ones that ever did anything. - -## What was wrong - -`AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL -dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` -has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and -nothing told a host that answered otherwise. - -So a host that owns a SQLite datasource and answers the spelling its own stack uses -— knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself -lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` -is tiered "cannot answer, do not block", **a wrong answer and no answer were the -same answer**: the host that tried hardest to help got the residue arm, silently. - -## What it does now - -- **The vocabulary is declared**, on the type and in the docblock, as - `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the - config learns the accept set without running anything. The type and the runtime - membership set are generated from one `const` tuple, so a future widening cannot - land in one and miss the other. -- **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, - the answer and the accepted set. It is emitted **once per distinct unrecognised - spelling** — the failure's identity — so the line count is bounded by the host's - own hook and never grows with query volume. -- **`undefined` stays silent and legal.** The hook is optional and "cannot answer, - do not block" is a supported composition, not a misconfiguration. A pin holds both - halves, because a diagnostic that also shouted at hosts who wired nothing would be - a worse defect than the one being fixed. -- **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex - aliases would be a second copy of that driver's table, and an unrecognised - spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as - `unknown`; only the silence changed. -- **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` - carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it - would have presented a correctly-behaving driver as a host answering out of - contract. It now arrives as `undefined`, this hook's own spelling for the same - thing. The dialect the compilers end up with is unchanged either way. - -## Measured, and worth reading before relying on the residue arm - -Driven on sql.js through a host answering `sqlite3`, against the shared -`FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of -the six case-EXACT cases come back with the wrong rows** — every case that -discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` -where the table says `['2']`, and the negated form DROPS a row that belongs in the -result. That is #15684's fold, live on the arm this population lands on, and it is -reported rather than fixed here: closing it is that card's business, not this one's. diff --git a/.changeset/analytics-time-dimension-granularity-buckets.md b/.changeset/analytics-time-dimension-granularity-buckets.md deleted file mode 100644 index 73deae054a..0000000000 --- a/.changeset/analytics-time-dimension-granularity-buckets.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/objectql": minor -"@objectstack/driver-memory": minor ---- - -fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) - - - -**BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in -the launch window as `minor` under the lockstep convention this cluster's -siblings already use: - -- an accepted request now answers **differently**: a time dimension carrying a - `granularity` folds its rows into calendar buckets instead of returning one - group per distinct timestamp. Every affected answer was wrong before; -- a **trend query answers rows where it used to answer one total**: a - `granularity` on a member `dimensions` does not also list is now a group - column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` - — the canonical trend shape — comes back one row per bucket, carrying the - member and a `fields` entry for it, instead of a single ungrouped total with - no such column; -- an accepted request is now **refused**: `granularity: 'second' | 'minute' | - 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. - -## What was wrong - -`AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube -dimension enumerates the granularities it offers (`granularities: ['day']`). -`memory-analytics.ts` read neither. The `$group` stage keyed on the raw field -path, so a time dimension bucketed **one group per distinct timestamp** — one bar -per row in a "new accounts by month" chart, which is the symptom #3588 -catalogued and repaired for `service-analytics`. - -Measured through the public entry against the built package, two rows on one UTC -calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under -`granularity: 'day'`: - -| | before | after | -|:--|--:|--:| -| `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | -| no granularity (control) | 2 groups | 2 groups, unchanged | -| `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | -| same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | -| `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | - -The emitted pipeline was byte-identical across all three, which is the whole -finding: the request was accepted, no warning was emitted, and the key was inert. - -## What it does now - -- **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, - granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and - the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only - statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name - the five granularities that HAVE a canonical key, so a face that must refuse - the other three quotes the accepted set instead of hand-listing it. -- **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and - signature unchanged, answers unchanged — pinned across granularity, timezone - and input form rather than asserted. A driver that pushes the bucket down into - SQL and this in-memory path must label one instant identically or a drill-down - breaks at the seam, and that is now one function rather than an agreement - between two. -- **A granular time dimension is a group column, listed or not.** `dimensions` - no longer decides alone what `$group` keys on: every `timeDimensions` entry - carrying a `granularity` is grouped, projected and named in `fields`, deduped - against `dimensions` on the resolved member so two spellings of one member - stay one column. This is the rule the SQL/ObjectQL face already records - (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping - and field metadata, because rows carrying a bucket under a `fields` list that - never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a - `dateRange` is a predicate and is still **not** projected. -- **`driver-memory` folds by granularity before its `$group`.** The pipeline is - cut at that stage: the `$match` half still runs in the driver, the bucket keys - are written onto the selected rows, and the grouping half runs over those. The - key travels under a synthetic field rather than overwriting the row's own, so a - member that is both a group key and a measure's aggregand still ranks instants - in `max()` while grouping on the label. -- **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, - `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's - `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an - output contract, and a second spelling is what breaks a drill-down across a - backend seam. -- **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone - #16042 threaded through the `dateRange` window resolver, so the window that - selects the rows and the bucket that folds them agree on where a calendar day - starts. The same two rows answer one group in UTC, two in `America/New_York` - and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. - - ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver - reads in the reference zone. An explicit `[start, end]` array is the caller's - own **instant** window and keeps its published reading (#16179), while the - bucket beside it is always a **calendar** label (ADR-0053) — so an array - window and a bucket can still disagree about where a day starts. That - combination is legitimate and is not refused; it is stated here rather than - left to be discovered. -- **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 - envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, - the class `refusePerAggregationFilter` uses for the same reason: the query is - spelled correctly, the spec declares the value, and it is this backend that - compiles nothing for it). The canonical key vocabulary defines no label for a - sub-day bucket, so there is no string another backend's pushed-down SQL would - agree with. Passing it through unbucketed is this card's own defect wearing a - new name. -- **An undeclared granularity is a 400, not a 501.** A 501 says "this backend - cannot", which is only honest about a value the contract declares. - `TimeUpdateInterval` is checked first, so a spelling it never declared — - reachable past the schema door, where `POST /analytics/dataset/query` types - `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` - / 400 rather than a 501 asserting the spec declared it. The same separation - the `dateRange` half of this face already draws (#16322 / #16041). - -## If a caller is refused - -A stored widget or a request asking for a sub-day granularity was never bucketed -by this backend — it received one group per distinct timestamp under an ordinary -200. Nothing that worked stops working. Ask for `day` or coarser and the answer -is a real bucket; keep the raw timestamps deliberately by dropping the key, which -is the behaviour that key used to produce by accident. diff --git a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md b/.changeset/approval-approvers-manager-rung-may-resolve-empty.md deleted file mode 100644 index e4f6b467f8..0000000000 --- a/.changeset/approval-approvers-manager-rung-may-resolve-empty.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. - -The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. - -- **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. -- **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. -- **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. -- **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. - -Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. - -This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. - -No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. diff --git a/.changeset/approvals-terminal-run-status-exhaustive.md b/.changeset/approvals-terminal-run-status-exhaustive.md deleted file mode 100644 index b1b25106fe..0000000000 --- a/.changeset/approvals-terminal-run-status-exhaustive.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) - -`ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. - -**Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. - -The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. - -No API change: the classification is module-internal and the package barrel is untouched. diff --git a/.changeset/artifact-granted-permissions-load-binding.md b/.changeset/artifact-granted-permissions-load-binding.md deleted file mode 100644 index e39281c242..0000000000 --- a/.changeset/artifact-granted-permissions-load-binding.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. - -`EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). - -Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. - -Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. - -New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. - -This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. diff --git a/.changeset/audit-write-failure-cause-keyed-report.md b/.changeset/audit-write-failure-cause-keyed-report.md deleted file mode 100644 index c10aa8edbc..0000000000 --- a/.changeset/audit-write-failure-cause-keyed-report.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. - -`reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. - -The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. - -The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. - -`@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. diff --git a/.changeset/auth-gate-allowlist-anchored.md b/.changeset/auth-gate-allowlist-anchored.md deleted file mode 100644 index 0e112f6168..0000000000 --- a/.changeset/auth-gate-allowlist-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/core": patch ---- - -`isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. - -The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. - -- **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. -- **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. -- **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. - -**If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. diff --git a/.changeset/auth-manager-single-flight-instance.md b/.changeset/auth-manager-single-flight-instance.md deleted file mode 100644 index dfa46ec380..0000000000 --- a/.changeset/auth-manager-single-flight-instance.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) - -`AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. - -The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. - -Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. - -`getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. - -No log level changed, in this package or any other. diff --git a/.changeset/automation-run-declaration-truth-residues.md b/.changeset/automation-run-declaration-truth-residues.md deleted file mode 100644 index 58c9695709..0000000000 --- a/.changeset/automation-run-declaration-truth-residues.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's - -Three corrections to text this package ships. No behaviour changes; every shape -described below is the ruled design, measured as it already is. - -**`variables_json` said `⇔` where only `⇒` holds.** The field description -declared "present on a completed/failed row" and "the row's run had a pause its -resume consumed before a downstream node failed" to be equivalent. The forward -direction holds — nothing but the consumed-suspension path writes that column on -a terminal row. The reverse does not, for one shape: a run that stranded, was -restored and then finished. `recordTerminal` upserts the SAME `run_` row -with all four snapshot columns explicitly `null` — deliberately, so -"restorable" cannot outlive the condition it describes — which leaves that row -equal, across every column the discriminator is read from, to the row of a run -that never paused at all. Absence means "nothing to restore now", never "this -run never had one", and the restore verb already refuses in exactly those terms: -it names the status it observed and declines to say which. The description now -says so. - -**A snapshot rebuilt from a row does not carry the step log as of the pause.** -`deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF -THE PAUSE". That is true of the engine's process-local journal copy only, which -slices `run.steps` back to the step count at the pause; the trimmed array is -never persisted. `steps` are the one field the rebuild takes from the row's own -`steps_json`, which is the terminal row's log of the WHOLE run — and both bounds -on that column keep the failure on purpose (history compaction retains every -failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries -steps the pause did not have. It re-arms the same run regardless: the pause is -`nodeId` plus `variables` / `context` / `correlation`, none of which the step log -feeds. - -**`recordTerminal` now names the verb that reads what it writes** — the -restore path in `engine.ts` — and the three properties of the write that are -that verb's inputs rather than local detail. Its summary line also said -"completed / failed" where the terminal vocabulary has had four members since -the fold was removed from both ends of this write. - -Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the -indistinguishability itself: the restored-then-finished row and a never-paused -row compare equal across those five columns, with the same comparison separating -them while the snapshot is still there. diff --git a/.changeset/basepath-normaliser-consolidation.md b/.changeset/basepath-normaliser-consolidation.md deleted file mode 100644 index b402f663ba..0000000000 --- a/.changeset/basepath-normaliser-consolidation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL - -`AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` -read `this.config.basePath` directly and added no leading slash, so a `basePath` -configured without one produced a value that is not a URL at all: - - basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp - -`new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted -well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 -refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource -identifier ... must be an absolute URI (RFC 8707 §2)"). With -`enforcePerClientResources` at its `true` default, every MCP client was then refused -for want of a link row. That input class could never mint or match a token, so -repairing it re-selects nothing. - -There is now exactly one read of the configured value and one chain above it: - - configuredBasePath() the configured value VERBATIM — what better-auth is handed - └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) - ├─ getAuthIssuer() = origin + this - └─ getBasePath() = this, trailing slashes stripped - └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` - -`getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every -spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: -a missing leading slash (was not a URL), repeated trailing slashes, or a configured -`/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all -three getters. diff --git a/.changeset/better-sqlite3-peer-record-remeasured.md b/.changeset/better-sqlite3-peer-record-remeasured.md deleted file mode 100644 index 52ba213f8a..0000000000 --- a/.changeset/better-sqlite3-peer-record-remeasured.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@objectstack/cli": patch -"create-objectstack": patch ---- - -fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) - -A tree containing `@objectstack/cli` reports an unmet peer on every fresh -resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares -`^13.0.3` — and the reading that decides what to do about it lived only inside -the scaffold generator's prose. No range moves here and no resolution moves: -what changes is the recorded reason, which had two measured errors in it, plus -a gate that now holds the declaration to that reason. - -**The declaration is correct and stays at `^13`.** Three readings, taken rather -than inherited: - -- The peer is `optional`, and it governs exactly one configuration — a raw - better-sqlite3 `Database` passed to better-auth's `database` option. - `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or - `undefined` for better-auth's in-memory adapter. Never a `Database`. -- better-auth cannot be incompatible with better-sqlite3 13, because it never - touches it: of the 464 files in the published `better-auth@1.7.2` tarball, - exactly one names better-sqlite3 — `package.json`, the peer declaration - itself — and no code file references it (positive control: `kysely` names 9). - It accepts a `Database` the caller constructs; its own sqlite test path uses - node's built-in `node:sqlite`. -- Pinning back to `^12` is not a neutral alternative. Measured on a bare - project depending on `@objectstack/cli@17.3.0`, it clears the report only by - resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that - nothing loads. The scaffold's existing `allowedVersions` entry clears the - same report with the lockfile byte-identical. - -**Two corrections to the record.** It credited `@objectstack/driver-sql` for -the 13.x copy; on the chain that actually reports -(`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the -CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. -And it was measured on better-auth 1.7.1 while the family has been pinned at -1.7.2 since — re-measured, with the empirical reading replaced by a structural -one. - -The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both -producers (`objectstack init` and the `create-objectstack` blank template); the -declarations, the widening entry and the resolution are untouched. diff --git a/.changeset/blank-node-condition-refused-at-registration.md b/.changeset/blank-node-condition-refused-at-registration.md deleted file mode 100644 index 1dbee29bbf..0000000000 --- a/.changeset/blank-node-condition-refused-at-registration.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow -node's `config.condition` — a `decision` node's predicate, and on a `start` node -the **trigger gate** — is now refused at `registerFlow` when its source is blank -after trimming, where it used to register clean and answer a **silent `false`** -at every evaluation. - -Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`, by name. A node's `config` is an open -`z.record(z.string(), z.unknown())`, so the same value passed through verbatim, -reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() -=== ''` — and returned `false`, under a comment that names that arm as being for -an **unauthored** condition. `' '` was authored. The branch never ran, forever, -with nothing said at any layer. - -```yaml -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form of phase M9.1) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs - the condition's source through `EvaluatedExpressionInputSchema` itself, so the - node door and the edge door cannot drift into two notions of "blank" or two - sentences for it — the property the #15662 campaign built the shared refusal - for. Nothing is exported from this package to carry it, and no new export was - added. -- **Applied to the SOURCE, not to the whole value**, deliberately: the union - would also refuse an envelope with no `dialect` or with a dialect outside its - enum, and this slot admits both (`structuralConditionRefusal`'s docblock, - #4336). The narrowing is exactly the blank population and nothing else — a - `cron` envelope with a real source still earns its own pre-existing verdict, - and a bare string with a `{…}` brace trap still earns #1491's. -- **`evaluateCondition` is unchanged and still answers `false`.** It is the - shared evaluator and a public method on an exported class, so its throw - behaviour is itself a contract; and a stored flow reaches it whatever the - producer refuses. This change is at the producer only. -- **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped - condition; the new refusal sits behind the shape one and in front of the CEL - one, and answers the evaluated-slot sentence rather than - `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. - -**What an author does with a refused condition.** A whitespace-only condition was -never a predicate — the engine answered `false`, so the branch never fired, and on -a `start` node the flow never triggered. **Remove the `condition` key** if the node -was meant to be unconditional, or **write the expression** if it was meant to -branch. ⚠️ Those two are not interchangeable: a refused condition never fired, -while an absent `condition` on a decision node is an unconditional branch that -always fires and an absent one on a start node is a gate that always opens. -Deleting the key to clear the refusal inverts the node rather than preserving it. -Every condition with a non-blank source is unchanged, and nothing is renamed or -retired. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, -not just the branch.** Stored flows are deliberately not canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize -at `registerFlow`, and each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So a node condition that used to answer a -silent `false` while the rest of the flow ran now takes the flow down with it: it -is never registered, its trigger is never armed, and the announcement is that one -warn line — `[Automation] failed to register flow` at boot, `[Automation] -cold-boot flow bind: failed to register flow` at the kernel:ready bind, -`[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line -is also the locator: the refusal names the node and the slot, e.g. `node 'gate' -(start) condition`. A stack authored in config files has a second door, -`objectstack validate` — see the note below for what that door does **not** yet -say. - -**A repo-wide census on this branch found zero authored `config.condition` values -of this shape**, against a lit control: a textual probe over all 8,123 tracked -source files found **461** non-blank `condition:` string literals and **zero** -blank-after-trim ones in any authored flow (the four blank hits are two prose -examples inside #15807's own changeset and two `packages/lint` test fixtures). -There is nothing in this repository to rewrite. - -⚠️ **Two follow-ups this change does not carry, both outside this card's package.** -(1) The ADR-0087 D3 entry named above, -`flow-edge-condition-evaluated-slot-source-required`, registers the decision this -change is a second face of — an evaluated slot requires a non-blank `source` — but -its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need -widening to `config.condition` so a consumer replaying the chain is told to sweep -the node key too; that file is in `packages/spec`. -(2) `@objectstack/lint`'s `validate-expressions` applies only -`structuralConditionRefusal` to a structural condition, so `objectstack validate` -still reports nothing for a blank `config.condition` that `registerFlow` now -refuses — the two doors disagree until that rule is rebound as well. diff --git a/.changeset/cli-register-requires-name.md b/.changeset/cli-register-requires-name.md deleted file mode 100644 index 0a72c1b94c..0000000000 --- a/.changeset/cli-register-requires-name.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) - -`os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. - -So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. - -- the prompt now reads `Name: `; -- an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; -- the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; -- the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. - -No behaviour change for anyone already passing a name, by flag or at the prompt. diff --git a/.changeset/client-adopts-rotated-session-token.md b/.changeset/client-adopts-rotated-session-token.md deleted file mode 100644 index 7347744748..0000000000 --- a/.changeset/client-adopts-rotated-session-token.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) - -Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: - -| route | where the new credential is | -| --- | --- | -| `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | -| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | -| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | - -A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. - -The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. - -**No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. - -## What does NOT change, deliberately - -The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. - -A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. - -The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. diff --git a/.changeset/client-environments-delete-purge.md b/.changeset/client-environments-delete-purge.md deleted file mode 100644 index 2799547d29..0000000000 --- a/.changeset/client-environments-delete-purge.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) - -The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. - -- `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. -- The return type declares the two answers the route actually sends, discriminated by `deleted`: - - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` - - teardown: `{ environmentId, deleted: true, purged: true, warnings }` - - Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. -- The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. -- `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. - -Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. diff --git a/.changeset/client-get-active-member-names-the-organisation.md b/.changeset/client-get-active-member-names-the-organisation.md deleted file mode 100644 index 245794df04..0000000000 --- a/.changeset/client-get-active-member-names-the-organisation.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) - -**BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. - -The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. - -It now asks the question honestly, in two requests: - -1. `GET /get-session` — the caller's own user id; -2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. - -`list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. - -## What an existing call observes, before and after - -Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. - -- **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. -- **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. -- **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. -- **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. - -Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. - -Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. - -The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. - - diff --git a/.changeset/client-get-session-envelope-and-refresh-read.md b/.changeset/client-get-session-envelope-and-refresh-read.md deleted file mode 100644 index ba6704d205..0000000000 --- a/.changeset/client-get-session-envelope-and-refresh-read.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) - -Both methods annotate their return as `SessionResponse` — ObjectStack's REST -`{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth -owns those bytes and answers **bare**. Measured against a real `AuthManager` -(better-auth 1.7.2, organization plugin) over a real driver: - -``` -GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} -GET /api/v1/auth/get-session (anonymous) -> 200 null -``` - -So `(await client.auth.me()).data.user` type-checked and was `undefined` at -runtime, while `.user` — the real payload — did not type-check. The annotation -pointed every caller at the wrong key. - -## What changed - -- The bare answer is now lifted into the declared envelope, the same lift - `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is - **unchanged** and so is each method's published return annotation: the fix is - in what the methods produce, not in what they promise. -- The lift fills `success` as well as `data`. `SessionResponseSchema` is - `BaseResponseSchema.extend(…)` and that base declares `success` as a required - boolean, so a body carrying `data` alone still would not parse as the declared - type. -- The raw `.user` / `.session` keys are **kept** alongside `data`. They are what - callers were pushed onto while the declared shape was unreachable; dropping - them would trade one silent breakage for another. -- `auth.refreshToken` now reads `data.session.token`. It used to read - `data.data?.token` — a field this route does not produce at any nesting, so - the method returned successfully having captured nothing. A bearer-mode client - calling it to refresh kept whatever credential it already had, silently. - -## The read was not a consequence of the envelope - -Worth stating because the reverse is the natural assumption: enveloping the body -does **not** put a token at `data.token`, because the route serves no top-level -`token` to lift. The only credential in the body is `session.token`, and that is -now the read. Fixing the shape alone would have left `refreshToken` exactly as -inert as it was. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `(await client.auth.me()).user` | still works — kept deliberately | -| `(await client.auth.me()).data.user` | now populated (was `undefined`) | -| `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | - -`refreshToken` stores the **unsigned** session token, which is the spelling -`/get-session` serves; `bearer()` accepts it and the signed -`token.signature` form interchangeably, so a client that held the signed form -stays signed in across the call. - -Two answers stay outside the declared type and are **not** addressed here: the -anonymous `null`, which would need the published return annotation to widen, and -`SessionUser.image`, declared `z.string().optional()` against a route that -serves `null` (#17235). The sibling `auth.login` / `auth.register`, which -normalize into `data` but set no `success`, are #17234. diff --git a/.changeset/client-invite-role-default-member.md b/.changeset/client-invite-role-default-member.md deleted file mode 100644 index eb6d7ef46f..0000000000 --- a/.changeset/client-invite-role-default-member.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/client": patch ---- - -fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) - -`organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: - -``` -client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) -``` - -Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). - -The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. - -Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: - -``` -before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} -after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} -``` - -No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. diff --git a/.changeset/client-packages-get-single-true-type.md b/.changeset/client-packages-get-single-true-type.md deleted file mode 100644 index c7791d29f7..0000000000 --- a/.changeset/client-packages-get-single-true-type.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) - -`client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. - -**Migration — read the row directly, not `.package`:** - -```ts -// before -const { package: pkg } = await client.packages.get('com.acme.crm'); -const pkg2 = (await scoped.packages.get('com.acme.crm')).package; - -// after -const pkg = await client.packages.get('com.acme.crm'); -const pkg2 = await scoped.packages.get('com.acme.crm'); -``` - -FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. - -This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. - -Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. - -The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. diff --git a/.changeset/config-refusal-throws-so-json-faces-emit.md b/.changeset/config-refusal-throws-so-json-faces-emit.md deleted file mode 100644 index 6466423d5e..0000000000 --- a/.changeset/config-refusal-throws-so-json-faces-emit.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) - -Every `--json` face in this CLI declares that it answers an error path with a -payload. `resolveConfigPath()` was the one path that bypassed that declaration: -it wrote its refusal and then called `process.exit(1)` **directly**, so nothing -was thrown and the catch-all each command already carries — all of which sit -downstream of a throw — never ran. Ten published faces answered a missing config -file with an empty stdout. - -Measured before this change on the published entry `packages/cli/bin/run.js`, -`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces -(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · -`migrate meta` · `validate` · `verify`) across both branches of the helper, 19 -runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B -(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the -same 19 runs answer **exit 1 with a parseable document on stdout**, stderr -unchanged byte for byte. - -The refusals now throw `ConfigRefusalError`. That is not a new contract — it is -this path being pulled back onto the one its callers had already published, so -it adds **zero** accept-set members and **zero** error codes. - -Three properties hold it in place: - -- **No face becomes a crash dump.** `os verify` had no `try` at all — measured, - a throw through it produced an oclif error line and no payload where every - sibling emitted an envelope — so it gains the catch-all its nine siblings - already had, in this same change rather than after it. -- **The text face does not narrow.** The refusal and both hint lines are still - written by the helper, to stderr, byte-identical: all 19 non-`--json` runs - compare equal before and after on stdout, on stderr and on exit status. The - catch-alls skip re-rendering the sentence a second time on stdout. -- **No error code is minted.** The thrown error carries neither `code` nor - `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits - its own bare `{ error }`. Whether that shape is right is **#15549**'s open - question, and this change deliberately does not answer it. - -The `--json` stdout-purity instrument is widened with the fix rather than after -it: the pre-boot family's discovery moves into a shared module, the pin that -drives it now demands a document (empty stdout no longer passes) and compares -the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` -— whose own discovery is `bootSchemaStack`-based and cannot see a command that -fails above the kernel — reconciles against that population so neither half can -be lost silently. diff --git a/.changeset/cron-typed-positions-retired.md b/.changeset/cron-typed-positions-retired.md deleted file mode 100644 index 5634313061..0000000000 --- a/.changeset/cron-typed-positions-retired.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) - - - -**BREAKING** — seven authorable positions across five schemas are DELETED. Executes the -maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family -recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the -route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no -`retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. - -Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into -the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 -ledger row `cron-declared-unwired` had every one of them `unevaluated`. - -| family | schema | deleted position | reachable from a stack manifest | -|:--|:--|:--|:--| -| export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | -| flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | -| connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | -| cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | -| backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | - -**What an upgrading author actually observes.** None of the five schemas is `.strict()`, so -a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and -still loads, and the value is discarded there without a word. There is nothing for -`objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value -was already inert before this change, and it is inert after. - -The parse is not the only channel, and the two that speak are worth stating exactly, -because a reader who stops at "non-strict schema" will conclude the opposite: - -- **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack - manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports - `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its - value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` - prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The - channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry - schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning - as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack - refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists - nothing, in either direction. -- **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, - `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an - excess-property error at the key and deletes it. - -The other six positions are not reachable from a stack manifest, so no CLI walk visits them: -for those the parse-level strip really is the whole of it. - -**What stays, byte-identical:** every other key of the five schemas and every export — no def -leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep -their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and -`nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the -key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the -ruling names, and exactly as inert as before. - -**One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required -`cronExpression: string` member is deleted from `ScheduleExportInput` in -`contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a -published runtime TS interface (both names are in `api-surface/contracts.json`). It follows -the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an -input demanding the key would ask a provider for a cadence it cannot store. The interface, -the method and every other member stay. Measured blast radius: no source outside -`packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo -(positive control: a symbol of the same class resolves outside `packages/spec` in the same -sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An -implementor that *does* exist off-tree drops the member from its object literal; a caller -constructing a `ScheduleExportInput` drops it from the literal it passes. - -**Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by -`croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` -(experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not -retired, on its sibling card). - -## This change states no before/after rewrite, because there is none - -A breaking changeset in this repo normally states the old spelling beside the new one. -This one has no such pair to state: the same document PARSES before and after, the value -was inert in both, and no conversion can be written for it — so a metadata upgrader has no -edit to make and `os migrate meta` has nothing to list. That is a statement about the -migration chain, not about silence: `os validate` / `os build` do name the dropped -connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and -the line for a TypeScript author. What follows is guidance for authoring a cadence going -forward, not a rewrite of an existing document. - -## What to write instead - -There is no replacement on any of the five schemas: no export scheduler, flow-state -scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner -exists to declare a cadence to. The one cron slot the platform evaluates is -`Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler -you write: - -```ts -// A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares -// the cadence as a job instead; the handler drives the connector. -defineStack({ - connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], - jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], -}); -``` - -The retirement kit, in the shape the 2026-09-10 ruling prescribes: - -- the key is DELETED at all seven sites (`api/export.zod.ts` ×2, - `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, - `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what - left, why nothing ever read it, and what does work instead -- **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 - conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is - the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector - family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 -- the four baseline rows that existed (`automation/ScheduleState:cronExpression`, - `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, - `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, - each carrying the #4650 proof the build computes for itself: the def is not reachable from - the 26 metadata-type roots. The three nested positions never had a row of their own -- no liveness-ledger row: none of the five schemas is an enrolled ledger type -- the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every - position it covered is gone, so discovery by roster name no longer sees them); the cron - dialect is now exactly the one evaluated slot plus the one experimental-by-design slot -- pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is - accepted and stripped and the enclosing block still parses, on the base schema and through - every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` - door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; - and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion - and no D3 semantic entry names any of the seven -- generated baselines and docs follow the schema: the five reference pages are regenerated, - the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps - `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching - `syncConfig.schedule` -- `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first - ratchets def *names* and the second export *existence*; deleting keys removes neither diff --git a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md b/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md deleted file mode 100644 index 933d4cdf19..0000000000 --- a/.changeset/dashboard-chartconfig-liveness-row-re-anchored.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) - -`packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. - -**The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. - -**The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. - -Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. diff --git a/.changeset/dashboard-item-level-property-names.md b/.changeset/dashboard-item-level-property-names.md deleted file mode 100644 index adc9e3e349..0000000000 --- a/.changeset/dashboard-item-level-property-names.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/rest": patch -"@objectstack/platform-objects": patch ---- - -feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) - -## What was wrong - -The Studio property panel renders `dashboard.header.actions[]` as a table whose -column headers read `items.properties[k].title ?? k` from the JSON Schema -derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields -(`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback -arm ran for every locale, English included, and the maker saw machine keys. -Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, -decorates the `FormFieldSpec` tree, which the table never reads. And the platform -catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared -no children under the composite, so `os i18n extract` emitted no -`header.showTitle` / `header.showDescription` / `header.actions` key and the -console shipped a private overlay for exactly those three. - -## What changed - -- **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author - `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the - derived JSON Schema names each column. New export - `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in - `@objectstack/spec/system`: every `metadataForms..fields..label` - at any locale of the chain becomes the `title` of the node the path addresses, - stepping through an array's `items` so a repeater ROW property is addressed - as `.` (`header.actions.label`) — the same path the - extractor emits. Pure; returns the input object itself when nothing applies. - `dashboardForm` enumerates the `header` composite's children - (`showTitle`, `showDescription`, `actions` with its four row properties) with - labels equal to the schema titles, pinned equal in `dashboard.test.ts`. - The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` - → "Metadata authoring forms". -- **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived - `schema` beside its `form`, through that overlay. -- **`@objectstack/platform-objects`** — the four generated `metadata-forms` - catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, - `ja-JP` and `es-ES`. - -Additive: no key removed, no accept set changed, no parsed output moved. - -`DashboardSchema.columns` deliberately still declares no `.default(12)`, and -the reason is stronger than the one #16458 assumed. The card reasoned that the -renderer already falls back to 12, which would make `.default(12)` -behaviour-preserving. Measured at objectui `origin/main` -(`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a -`columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` -yields 12 and everything else yields **4** — and the next line switches the -whole layout on that value (`hasExplicitColumns = schema.columns != null || -inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the -default would therefore both retire the inference and flip every auto-flow -dashboard into the positioned grid. A default that silently materialises a key -is expensive to take back, so the round stopped at the declared condition and -left the key alone; see #16458. diff --git a/.changeset/dashboard-stageorder-doc-names-only-funnel.md b/.changeset/dashboard-stageorder-doc-names-only-funnel.md deleted file mode 100644 index 2bffef7541..0000000000 --- a/.changeset/dashboard-stageorder-doc-names-only-funnel.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) - -`DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. - -- **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. -- **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. - -The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. - -No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. diff --git a/.changeset/dashboard-stageorder-gated-to-funnel.md b/.changeset/dashboard-stageorder-gated-to-funnel.md deleted file mode 100644 index 72ba949edb..0000000000 --- a/.changeset/dashboard-stageorder-gated-to-funnel.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. - -## What was wrong - -The key never failed. It failed to *order*. - -`options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. - -Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. - -So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. - -## What it does now - -`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. - -It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. - -The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | -| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | -| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | - -⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. - -## What the gate does NOT cover - -Stated so the change is not read as complete: - -- ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. -- **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. -- **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. -- **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. -- **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. - -## The siblings, measured and deliberately not touched - -`stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. - -## The other arm, deliberately not taken - -The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. diff --git a/.changeset/data-migration-flag-columns-moved-at.md b/.changeset/data-migration-flag-columns-moved-at.md deleted file mode 100644 index 81d599537d..0000000000 --- a/.changeset/data-migration-flag-columns-moved-at.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/platform-objects": minor ---- - -`DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. - -**What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. - -**Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. - -**Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. - -This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. diff --git a/.changeset/dataset-measure-aggregate-field-type-refused.md b/.changeset/dataset-measure-aggregate-field-type-refused.md deleted file mode 100644 index c6826dbf69..0000000000 --- a/.changeset/dataset-measure-aggregate-field-type-refused.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -"@objectstack/service-analytics": minor -"@objectstack/spec": minor ---- - -feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. A dataset -measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to -`AVG(col)` and reach the backend; it is now refused by `compileDataset` before any -query is built. Shipped as `minor` under the repo's launch-window convention for -accept-set narrowings; the hand-migration prescription is registered under protocol -major 18 as `dataset-measure-aggregate-field-type-refused`. - -The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table -`@objectstack/spec` declared in #16353 under the director ruling of decision batch -#59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and -restates none: the refusal reads the shipped predicate, so the contract has exactly -one statement. - -## What was wrong - -The answer to `AVG` over a temporal column was decided by the SQL dialect rather -than by the data. Both halves measured on this card: - -``` --- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) -select typeof(submitted_at), submitted_at from clm_contract limit 1; - text|2026-05-19T00:00:00.000Z -select avg(submitted_at) from clm_contract; - 2025.5 <- text->numeric coercion: the average YEAR - --- PostgreSQL 16.13 -select avg(submitted_at) from t; - ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 -``` - -The silent half is the dangerous one, and SQLite is the default dev datasource: -`derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned -`-0.85` and rendered on a tile labelled "average cycle time delta" — a number -indistinguishable from a correct one. Nothing refused it at any layer: not the -schema, not `os validate` / `os lint`, not the analytics service, not the renderer. - -## What it does now - -- `compileDataset` refuses an incompatible `aggregate` × `field` pair with - `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and - the accepted set (read off the table, never restated). Nothing reaches the driver. -- It reads the declared type from the `sourceFieldMeta` a host already wires, via a - new optional `DatasetCompileOptions.declaredFieldType` probe. -- **`derived` is covered by construction.** A derived measure's `of` operands are - base measures of the same dataset, so a dataset carrying a refused base measure - never finishes compiling and no `derived` op can be handed its output — including - when the selection names only the derived measure. -- Tiered "cannot answer, do not block" like every sibling probe: no - `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose - column lives on a joined object) leaves the pair unjudged. - -## ⚠️ Scope: the compile leg executes the TEMPORAL rows only - -The gate judges only a measure whose field is declared `date` / `datetime` / -`time`; a field of any other class is never handed to the predicate. The -verdict for the pairs it does judge is the table's — no row is restated — but -which FIELDS are judged is narrower than the table, on purpose: - -- **String rows** (`min` / `max` over `text`, `select`, `lookup`, - `autonumber`, …) are **not enforced here**. They are under #16785, **ruled - C**: the table itself is to be amended to accept them, because - `measureResultType` (#15768) already types those results as `'string'` and - pins them end to end. Enforcing them from this card would pre-empt that - ruling. -- **Boolean rows** are not a refusal at all any more: #16685 was ruled A and - #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the - table ACCEPTS them and this gate never judged them. -- The table's `sum` × `percent` row is likewise **not** executed by this leg; - `sum` over a `percent` compiles exactly as it did before. - -⇒ The only pairs whose behaviour changes in this release are `avg` / `sum` -over a `date` / `datetime` / `time` field. The full-table leg remains #16099's. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | -| `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | -| `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | - -⭐ A duration is not recoverable from an aggregate over instants on any backend. -Where an "average cycle time" is wanted, the cycle length has to exist as a number -before it can be averaged. - -## What is deliberately untouched - -`date` / `datetime` used as a **dimension** — grouping, bucketing, date-range -filtering — is unchanged; this is about aggregation only. `avg` over a genuine -numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` -over anything all behave exactly as before. - -⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in -`compileDataset` and reads a `declaredFieldType` probe, so it applies only where -a host wires one: `/analytics/query` — the non-dataset face, whose measures a -Cube infers rather than an author declaring them — is NOT covered, and neither -is any other `compileDataset` caller that passes no probe (those stand down -unjudged rather than guessing). Closing those is #16099's, not this card's. - -Alongside the refusal, `service-analytics`' contradictory annotations about what a -SQLite `Field.datetime` column physically holds are reconciled to one statement — -**seven** source sites plus two test narratives, not the four the card quoted. Some -said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it -IS an INTEGER epoch. Neither is current: since #3912 the column has ONE -storage form, canonical UTC text, with the epoch surviving only in a database not -yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on -`AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. -No behaviour changes from that half. diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md deleted file mode 100644 index 8f14f660ed..0000000000 --- a/.changeset/dataset-select-dimension-option-i18n.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains -one new optional key, `translateSelectOptions`** — the surface the level is graded against, -per the same "a new key on a published exported type is the mechanical floor for clause ②" -rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, -no wire-shape change), so `minor` rather than `major`. - -A dataset's `select`-field dimension now renders its option label in the request's locale on -a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list -grid) already renders for the identical field. - -`dimension-labels.ts` resolved a select dimension's category label straight out of field -metadata's authored `options[].label` — always the author's own-language text, since -`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted -cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field -name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` -skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on -that path, and a regression test now pins that it is never even consulted. - -`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the -plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) -— the SAME translator the object-metadata REST endpoint already uses — against the -deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec -key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, -and a kernel with no i18n service configured (or nothing for the requested locale) falls back -to exactly today's authored-label text. - -A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / -master_detail labels resolve through the separate `fetchRecordLabels` capability (a related -RECORD's display name, not a field's authored `options[]`), which this change does not touch. -It does lower the cost of adding translated lookup-record labels later, though — the i18n -service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already -wired into this package and is a `ctx.getService('i18n')` away from reuse. diff --git a/.changeset/date-range-preset-window-extent.md b/.changeset/date-range-preset-window-extent.md deleted file mode 100644 index 571b7bef76..0000000000 --- a/.changeset/date-range-preset-window-extent.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) - -`DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. - -- **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. -- **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. - -Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. - -The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. - -No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. diff --git a/.changeset/declared-refusal-relay.md b/.changeset/declared-refusal-relay.md deleted file mode 100644 index c8860b58f6..0000000000 --- a/.changeset/declared-refusal-relay.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/runtime': patch -'@objectstack/metadata-protocol': patch ---- - -A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. - -`ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. - -The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. - -**What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. - -**What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. - -**For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. diff --git a/.changeset/deriving-aggregate-nonnumeric-field-refused.md b/.changeset/deriving-aggregate-nonnumeric-field-refused.md deleted file mode 100644 index 7095a5b8d2..0000000000 --- a/.changeset/deriving-aggregate-nonnumeric-field-refused.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -"@objectstack/service-analytics": minor ---- - -feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, continuing the -one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or -`avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach -the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** -before any query is built. Shipped as `minor` under the repo's launch-window convention -for accept-set narrowings. - -⛔ This changeset adds no rows to any table and restates none. The verdict is -`AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in -#16353 under the director ruling of decision batch #59 ("both legs, table in spec") — -read through `isAggregateCompatibleWithFieldType`. - -## What was wrong - -#16778 landed the compile leg SCOPED to temporal source fields, leaving "every other -non-temporal pair the table refuses" as a stated residual that had never been driven. -Driven on this card, through the real service door: - -``` -sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted -sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; - minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; - residual 107 — and 107 of 107 were ACCEPTED by the compile leg -control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted -``` - -The control is what makes that a reading of the tree rather than of a blind harness: the -same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. - -So `sum` over a `text` column reached whichever backend the object is bound to, and the -answer was a property of the dialect rather than of the data — the shape Prime Directive -#12 exists to remove, and the same shape #16778 closed for one field class. - -## What it does now - -- `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) - against the table for **every** declared field type, and refuses an unaccepted pair - with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type - and the accepted set read off the table. Nothing reaches the driver. -- `sum` × `percent` is refused at last: the row `analytics-service.ts` has called - "incoherent" in a comment since before the table existed. `avg` × `percent` is still - ACCEPTED by the same table, which is what makes it a row and not a class. -- The refusal's closing prescription is now chosen by the source field's class: the - temporal sentence #16778 measured is kept verbatim for temporal fields, and a - non-numeric field is pointed at `count` / `count_distinct`, which accept every type - because they read no arithmetic off a value. -- Unchanged: `derived` is covered by construction (a dataset carrying a refused base - measure never finishes compiling), and the three "cannot answer, do not block" tiers — - no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. - -## ⚠️ Scope: the DERIVING aggregates. `min` / `max` are still not judged here - -`min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the -line this package already draws — `measureResultType` branches on exactly that pair of -aggregates — and the defect is about a derived number, so the deriving aggregates are its -population. - -The `min` / `max` rows stay with **#17513**, and that is measured rather than assumed. -Enforcing the residual whole was tried on this card: with `min` / `max` × the string -classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every -one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven -end to end by the same shared fixture as the string rows. One dataset compiles every -measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is -one question, and it is the table-amendment card's. - -## Upgrading — FROM → TO - -Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and -`DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of -values are accepted. The one-line fix, per shape: - -| FROM (compiled before, refused now) | TO | -|---|---| -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | -| `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | -| `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | -| `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | -| `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | - -`min` / `max` are **not** affected by this change at all, over any field type. - -No shipped dataset in this repository declares a newly-refused pair — every one of the -eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. -The refusal names the accepted set for the aggregate, read off the table. diff --git a/.changeset/discovery-services-route-follows-mount.md b/.changeset/discovery-services-route-follows-mount.md deleted file mode 100644 index 90c2d96de6..0000000000 --- a/.changeset/discovery-services-route-follows-mount.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) - -The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. - -Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: - -- before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) -- after — both answer `/api/v1/objects` - -The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. - -**A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. - -If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. diff --git a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md b/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md deleted file mode 100644 index 704dca4544..0000000000 --- a/.changeset/driver-config-registry-off-vocabulary-lookup-guard.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema - -`DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` -are plain object literals, so all three inherit `Object.prototype`, and every -lookup into them was a bare index. Measured against the built artifact -(`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that -names an inherited member resolved that member and was handed onward as if it -were a driver: - -| call | before | after | -|:--|:--|:--| -| `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | -| `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | -| `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | -| `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | -| `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | -| `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | -| `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | -| `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | -| `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | -| `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | - -`getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an -empty JSON Schema validates anything, so a Studio connection form or a -`DriverDefinitionSchema.configSchema` consumer that asked "what shape must this -config have" was told "any shape at all" and reported success. - -The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an -unclaimed operator selection with `if (driverType && !kind)` after calling -`resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy -`kind` that is not a driver id and walked past the refusal. - -All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. -This narrows and widens nothing: every legal spelling is an own key of its table, -so no value accepted before is refused now, and only answers that were never -inside the declared return types move. The declared signatures are unchanged — -`getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` -and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. - -A null-prototype table was the other available shape and was measured rather than -assumed: a `__proto__: null` object literal does not type-check against the -`Readonly>` annotation at all (TS2353), and the -`Object.assign(Object.create(null), …)` spelling that does compile silently costs -that annotation — a table missing a driver stopped failing to compile (TS2741). diff --git a/.changeset/driver-sql-doors-declared-types.md b/.changeset/driver-sql-doors-declared-types.md deleted file mode 100644 index 58454d1397..0000000000 --- a/.changeset/driver-sql-doors-declared-types.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/driver-sql': minor ---- - -feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. - -Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. - -`@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. - -Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. - - diff --git a/.changeset/driver-turso-doors-declared-types.md b/.changeset/driver-turso-doors-declared-types.md deleted file mode 100644 index 921554eee0..0000000000 --- a/.changeset/driver-turso-doors-declared-types.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/driver-turso': minor ---- - -feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. - -Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. - -`explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. - - diff --git a/.changeset/driver-turso-remote-declared-indexes.md b/.changeset/driver-turso-remote-declared-indexes.md deleted file mode 100644 index e4fb50a88b..0000000000 --- a/.changeset/driver-turso-remote-declared-indexes.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) - -## What was wrong - -In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). - -## What changes - -- Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. -- New tables get their indexes in the same batch as `CREATE TABLE`. -- **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. -- An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. -- Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. - -## Upgrading - -Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. diff --git a/.changeset/engine-text-operator-declared-type-door.md b/.changeset/engine-text-operator-declared-type-door.md deleted file mode 100644 index f5962643db..0000000000 --- a/.changeset/engine-text-operator-declared-type-door.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/spec": patch ---- - -feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) - - - -**BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). - -## What was wrong - -Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: - -| filter | before | after | -|:--|:--|:--| -| `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | -| `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | -| `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | - -What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. - -## What it does now - -- **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. -- **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. -- **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. -- **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. -- **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. - -## Deliberately unjudged - -- **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. -- **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. -- **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. -- **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. - -## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` - -`@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. - -It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: - -| stored surface | how it reaches the door | -|:--|:--| -| `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | -| `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | -| `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | -| dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | - -**Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. - -No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | -| `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | -| `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | -| `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | -| `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | diff --git a/.changeset/engine-verb-result-declarations.md b/.changeset/engine-verb-result-declarations.md deleted file mode 100644 index 300876eb9b..0000000000 --- a/.changeset/engine-verb-result-declarations.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": minor -"@objectstack/plugin-auth": minor ---- - -feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) - - - -**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` -- `delete` → `Promise` - -`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. - -**Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). - -The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. - -**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. - -**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. - -**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: - -- `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. -- `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. -- `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. - -The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. diff --git a/.changeset/engine-write-failure-log-level-warn.md b/.changeset/engine-write-failure-log-level-warn.md deleted file mode 100644 index 0e7f502f7d..0000000000 --- a/.changeset/engine-write-failure-log-level-warn.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) - -`insert`, `update` and `delete` each end their `catch` with `throw e`, then -logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log -levels* names that exact shape and forbids it: "a failure handed to the CALLER -is not a degradation at all … Do not bolt a `logger.error` onto such a site." - -**This moves published behaviour**, which is why it is a changeset rather than a -`skip-changeset`: the level is what an operator greps, and at least one consumer -reads it structurally. `scripts/publish-smoke.sh` fails a boot on any -error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was -found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` -mode and documents its `identifier` UNIQUE constraint AS its race-safety -mechanism, catching the collision and continuing at `debug`. Our line was -emitted before that catch ever ran, so a healthy first boot of every fresh -`create-objectstack` project printed `ERROR Insert operation failed` and red-lit -`publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose -auth and CRUD probes were all green. - -**Nothing else about the entry moved.** Same message, same `object` meta, same -redaction (#8682: the bound statement and its values stay cut from `message` -and `stack`), same subject (#14095: the entry carries the driver's own error — -a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, -MySQL's index name and the driver's frames survive). The `Logger` contract gives -an `Error` slot to `error`/`fatal` only, so the engine now builds the -`{ error: { message, stack } }` bag that slot used to build; handing the Error -to `warn` as meta would have serialised `{}`, because those two fields are -non-enumerable. The rendered line is byte-identical apart from the level word, -and that equivalence is pinned rather than asserted. - -If you grep your logs for these three messages, keep the message and drop the -level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, -a refused write no longer raises one — the write's exception still does. diff --git a/.changeset/error-code-ledger-boot-refusal-prose.md b/.changeset/error-code-ledger-boot-refusal-prose.md deleted file mode 100644 index 5c9d05e3fa..0000000000 --- a/.changeset/error-code-ledger-boot-refusal-prose.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. - -`packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). - -The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. - -The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. - -No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. diff --git a/.changeset/example-caption-fence-assertion.md b/.changeset/example-caption-fence-assertion.md deleted file mode 100644 index c9daf17ef5..0000000000 --- a/.changeset/example-caption-fence-assertion.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, -instead of publishing an orphaned caption. - -`@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer -acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence -follows. Nothing asserted that one did. When a module header captioned a listing and wrote its -rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on -paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no -`remark-breaks`. Two customer-facing reference pages shipped that way. - -The assumption is now a precondition the generator checks before it emits anything. A module -description whose caption has no block under it fails the docs build with a message naming the -caption and the source-side fix, the way the renderer already refuses a heading it cannot -renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the -wrong page impossible instead of detecting it afterwards, and it is scoped to the population -the renderer actually renders — module doc blocks — rather than to every `@example` line in the -package. - -⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly -what this renderer refuses to do, and what an author writes instead of a fence is not knowable -from the text. It asks only the question the contract already states: is there a block beneath -the caption? An author who wants those words as ordinary prose writes them without the tag. - -Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop -re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All -twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. diff --git a/.changeset/field-notnull-prescribes-storage-not-required.md b/.changeset/field-notnull-prescribes-storage-not-required.md deleted file mode 100644 index 30bd42c35e..0000000000 --- a/.changeset/field-notnull-prescribes-storage-not-required.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) - -Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. - -The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. - -All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: - -> physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. - -Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. - -**No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. - -One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. diff --git a/.changeset/field-type-refused-at-registration-door.md b/.changeset/field-type-refused-at-registration-door.md deleted file mode 100644 index 805fbb0093..0000000000 --- a/.changeset/field-type-refused-at-registration-door.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": minor -"@objectstack/driver-sql": minor -"@objectstack/cli": minor ---- - -fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) - - - -**BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. - -**What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. - -## What was wrong - -One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: - -| declaration | driver | `os generate migration --format sql` | `--format ts` | -|:---|:---|:---|:---| -| `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | -| `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | - -`SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. - -## What it does now - -- **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. -- **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. -- **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. - -## Scope, stated rather than left to be inferred - -`SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. - -ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. diff --git a/.changeset/file-family-bare-id-column.md b/.changeset/file-family-bare-id-column.md deleted file mode 100644 index 538b39f1fb..0000000000 --- a/.changeset/file-family-bare-id-column.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) - - - -**BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. - -Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -**The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. - -**What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: - -- the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; -- a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. - -**What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. - -`multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. - -**The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. - -**Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. diff --git a/.changeset/filter-operator-schema-projection.md b/.changeset/filter-operator-schema-projection.md deleted file mode 100644 index 015ca262e1..0000000000 --- a/.changeset/filter-operator-schema-projection.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page - -`z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON -form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` -is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four -`data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, -`$lte` and `$between` reached no reference row. Not a blank Description cell: no -section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand -contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the -platform's own date-macro resolver produces — reached no reader. - -The generator now makes a third attempt when both strict directions refuse: it -projects with Zod's `unrepresentable: 'any'`, marks every node that came back with -no structural keyword, and DROPS the marked ones that are direct members of an -`anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON -documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON -documents that union accepts is unchanged by the drop. - -⛔ A marked node anywhere else — an object property, a record value, an array item -— refuses the projection and the export is skipped with the message Zod threw, so -this cannot change WHY anything is skipped. Five exports leave -`unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus -`data/Hook`, whose only unprojectable member was the deprecated inline-function -handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet -for the first time. - -Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, -NormalizedFilter,RangeOperator,Hook}.json`, each carrying an -`x-unprojectable-branches` record naming exactly which branch the projection -dropped and where. diff --git a/.changeset/filter-orthography-binding-and-object-blocks.md b/.changeset/filter-orthography-binding-and-object-blocks.md deleted file mode 100644 index a9f2068f7a..0000000000 --- a/.changeset/filter-orthography-binding-and-object-blocks.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) - - - -**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` -(the `dataSource` binding every data-bound page component carries) and -`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | -'object-calendar'].filter` — shipped as `minor` under the repo's launch-window -convention for breaking changes; the migration prescription is registered under -protocol major 18 as ONE entry for the family. - -One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, -verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, -verbatim 「同意」, option A: converge family-wide). Until this release the -binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it -refused the array the consumer's own pins author at that key, and -`element:record_picker` carried two orthographies at two keys resolved through -one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` -and took the record, the ObjectQL AST tuple array and the rule array alike, -silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every -other `filter` door in the map already carried; the `FilterConditionSchema` -import that existed in `page.zod.ts` for this one site leaves with it. - -Sequenced measurement-first, as the family had to be: at the objectui pin -`a472b07` the `object-metric` aggregate path posted an array `where` that -`POST /analytics/query` refused (400 on every array form, #15828), so the -converge was parked behind the pin bump #16626. At the pin this repo builds -against (`53ded82b`, objectui#7754) the adapter lowers an authored array through -`translateFilterArray` and the spec's own `parseFilterAST` sink before the -wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / -`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` -lowers it; the binding's composition seam AND-combines it with the named view's -rules through `mergeFilterNodes`. Nothing on those paths parses the value -against the installed spec. - -**Migration** (`element-data-source-and-object-block-filter-rule-array` — -listed by `os migrate meta --from 17` once the protocol major is 18): a -record-form `filter: { status: 'active' }` becomes -`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an -operator object `{ status: { $ne: 'done' } }` becomes -`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys -become several rules (they AND); an AST tuple array -`[['owner_id', '=', '{current_user_id}']]` becomes -`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — -placeholders and date macros are unchanged. The record form is refused at -`filter` (`invalid_type`, expected array); the tuple array is refused at -`filter.0` (expected object). The dashboard widget `filter` -(`dashboard.zod.ts`) is a different family and is unchanged by this release -(#15829); `object-grid.defaultFilters` is a different key, not named by the -ruling, and is unchanged. - -In-repo authors migrated in the same change: four spec test fixtures at the -binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint -fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, -`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: -`operator` normalizes on parse, so input ≠ infer at these five schemas now). diff --git a/.changeset/flow-edge-condition-evaluated-slot.md b/.changeset/flow-edge-condition-evaluated-slot.md deleted file mode 100644 index f36ffd00ed..0000000000 --- a/.changeset/flow-edge-condition-evaluated-slot.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): the -edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate -`AutomationEngine.evaluateCondition` runs at every traversal — now refuses at -authoring an envelope the engine cannot evaluate, where it used to parse, -register, pass `objectstack validate`, and then answer a **silent `false`**: a -branch that quietly never fired. - -Two spellings of one seam, refused by ONE rule with one sentence -(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the -`assignment` value envelope): - -```yaml -edges: - - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it - - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming - - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source -``` - -> An expression in an evaluated slot needs a non-blank `source`: the expression -> engine evaluates `source` (the canonical persisted form of phase M9.1) and -> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` -> that is blank after trimming, would validate and register and then fault at -> run time. Write `{ dialect: 'cel', source: '…' }`. - -- **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), - the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string - shorthand still normalizes to `{ dialect: 'cel', source }`, but the string - must be non-blank after trimming, and the envelope arm composes - `EvaluatedExpressionSchema` (`source` required and non-blank) instead of - `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose - it. An `ast`-only envelope and a blank bare string surface as one - `invalid_union` issue at the slot carrying the sentence above; a blank - `source` inside an envelope surfaces as one `custom` issue at `source`. -- **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain - the persistence contract (`source` OR `ast`), whose docblock declares that - `ast` becomes required in build output at phase M9.2. When AST-only - evaluation lands, `EvaluatedExpressionSchema` is the one place to relax, and - every evaluated slot follows. -- **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on - either structural condition slot (`config.condition` on a node, - `edge.condition`). #15662's refusal admitted it on purpose through a - `rec.ast !== undefined` clause, because the spec still admitted the shape at - `edge.condition` and refusing it from the consumer side would have decided - #15430's question there; with the edge schema closed, that admission kept the - refusal deliberately holed for a shape the engine cannot run on either slot. - `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope - carrying a string `source`" and says why. Consequence on `config.condition` - (a start node's trigger gate, a decision node's predicate — an open record - with no schema in front of it): an `ast`-only envelope there is refused at - `registerFlow`, reported as a located `error` by `objectstack validate`, and - refused by `evaluateCondition` with the same sentence, instead of answering a - silent `false`. An `ast` BESIDE a string `source` is still admitted - everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: - consistent `false` on both sides) is untouched. -- **Three doors agree, through the spec.** `registerFlow` refuses the flow at - `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); - `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse - (edge) or reports the structural refusal (`config.condition`); - `evaluateCondition` refuses the shape a stored flow or a direct caller hands - it. None of them grew a rule of its own. - -**What an author does with a refused edge condition.** An edge condition that -carried only `ast` has no evaluable form under M9.1: author its `source`. A -whitespace-only condition — envelope or bare string — was never a predicate -(the engine answered `false`, so that edge never fired): remove the -`condition` key if the edge was meant to be unconditional, or write the -expression if it was meant to branch. Every edge condition with a -non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — -the refusal itself carries the prescription. - -**A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole -flow, not just the edge.** The paragraph above is the author's remedy, at -`objectstack validate` / `POST /flows`; a stored row has no author in front of -it. Stored flows are deliberately NOT canonicalized by -`applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same -skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node -conversions need the automation engine's live executor registry, so flows -canonicalize at `registerFlow` instead, which parses through -`canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in -`service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one -`warn` naming the flow, and continues. So an edge that used to answer a silent -`false` while the rest of the flow ran now takes the flow down with it: it is -never registered, its trigger is never armed, and the only announcement is that -one warn line — `[Automation] failed to register flow` at boot, -`[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready -bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That -warn line is also the locator: its `issues[].path` names the offending edge — -`edges[N].condition` — beside the sentence above, so nothing has to be exported -to find it. Author the `source` — or remove the key, if the edge was meant to -be unconditional — and republish. A stack authored in config files has a second -door, `objectstack validate`, which locates the same edge at -`flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry -`flow-edge-condition-evaluated-slot-source-required`, which carries the same -judgment for a consumer replaying the chain. - -Not touched here: `start.config.condition` has no Zod schema to narrow (the -start node's `config` is an open record); its producer-side gate is the -structural refusal above, which this change tightens but does not type. diff --git a/.changeset/fold-admission-tenancy-posture-classification.md b/.changeset/fold-admission-tenancy-posture-classification.md deleted file mode 100644 index 8a3dbb01c4..0000000000 --- a/.changeset/fold-admission-tenancy-posture-classification.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/rest': patch -'@objectstack/cloud-connection': patch -'@objectstack/plugin-sharing': patch -'@objectstack/service-datasource': patch -'@objectstack/service-settings': patch -'@objectstack/service-storage': patch ---- - -refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) - -Six admission doors each hand-wrote the same try/catch on the `tenancy` read that -feeds `resolveAuthzContext`: the registry's branded "never registered" rejection -(`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the -supported no-tenancy composition, where no posture-conditional refusal runs at -all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` -(ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization -INPUT and admission was therefore never DECIDED. That is #13906 decision 1 -option A, and it is the part nobody may get wrong: a quiet `catch` at any one of -the six re-opens the defect, where a failure reads as "this check does not apply" -and an ex-member's org-stamped API key is admitted. - -Nothing is broken today — every copy was correct — so this removes a standing -hazard rather than fixing a defect. **No admission verdict changes**, on any -wiring: the classification is byte-for-byte the decision the six copies made, -now made once. - -- **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the - `TenancyServiceResolver` type), exported from the package index beside - `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. - The thunk is not a style choice: the REJECTION is what gets classified, so the - resolution has to happen inside the helper's `try` — a caller that awaited the - service first would need a `catch` of its own, which is the thing being - deleted. -- **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on - kernel-vs-provider, and asking twice would let a provider bound to the local - kernel answer for a request that resolved to another environment; four seams - read `ctx.getKernel()`; `service-storage` reads an already-normalised gate - registry; and each seam's reason why a MISSING async accessor must stay quiet - is its own argument (the storage door's is its declared degrade-to-ungated - contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper - that also owned how the service is reached would be wrong for one of them or - grow a flag per seam — the copies again, with an extra step. Every one of - those reasons stays written at its seam. -- **Folded**: `packages/rest/src/rest-server.ts` (both wirings), - `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, - `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, - `packages/services/service-datasource/src/admin-routes.ts`, - `packages/services/service-settings/src/settings-service-plugin.ts`, - `packages/services/service-storage/src/storage-service-plugin.ts`. -- **Pinned where the decision now lives**: - `packages/core/src/security/admission-tenancy-posture.test.ts` drives both - rejections at the production seam — a real `ObjectKernel` that never - registered `tenancy`, and one whose `tenancy` factory throws — each beside the - brand predicate's own answer on that same rejection, so "the outage throws" is - distinguishable from a helper that throws at everything. It also holds the - constraint mechanically: the helper's source may not name an accessor, a - kernel or a plugin context, and it takes exactly one parameter. diff --git a/.changeset/generate-migration-emits-declared-unique-index.md b/.changeset/generate-migration-emits-declared-unique-index.md deleted file mode 100644 index 07676f0bb3..0000000000 --- a/.changeset/generate-migration-emits-declared-unique-index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) - -## What was wrong - -Both migration formats emitted the table and none of the object's declared -uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all -three producers into three schemas, `pg_indexes` read back per schema: - -```ts -{ name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } -``` - -| producer | before | after | -|:--|:--|:--| -| `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | -| `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | -| `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | - -Two rows with the same `keyed_unique` value were refused by the platform's table -(`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted -by both generated ones, with nothing reporting it: a scaffold that creates the -table for an object silently dropped a uniqueness guarantee the object declares. -After the change the duplicate is refused by all three, each naming the same -constraint. - -The key set was not missing — it was already computed here to size the keyed -text family's columns; only the index it implies was never emitted. - -## What it does now - -- **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. - That is what knex's `table.unique(columns, { indexName })` — the driver's own - call — compiles to on PostgreSQL, so a generated table and a platform-created - one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside - the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD - CONSTRAINT` has no spelling for. -- **`--format ts`** emits that knex call itself, `indexName` included — which is - what makes the driver recognise the constraint as already present on its first - boot against a generated table, instead of adding a second one under its own - name and then reporting the generated one as an orphan to drop. -- Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned - against the driver's own export (a CLI production module may not statically - value-import a driver package). - -## What it deliberately still does not emit — and now says so - -Both formats print a `NOT EMITTED:` line naming the index, its key parts and the -reason, instead of dropping it silently: - -- the **organization-scoped composite** (`unique: true` / `'organization'` on an - object with an organization column), whose key part is - `COALESCE(, '__global__')`. Emitting the bare composite - instead would be worse than emitting nothing: under SQL's NULL-distinct - `UNIQUE` it constrains no row that has no organization, which on a - single-tenant deployment is every row. -- an index over a column no field materialises (a virtual `formula` field) — - the same skip the driver performs, where the driver logs a warning. - -Object-level `indexes[]` remains unemitted by both formats; it is normalized by -a different driver-side rule and is not covered by this change. diff --git a/.changeset/generate-name-charset-gate.md b/.changeset/generate-name-charset-gate.md deleted file mode 100644 index 025e61854b..0000000000 --- a/.changeset/generate-name-charset-gate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) - -Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. - -⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. - -**What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). - - diff --git a/.changeset/generator-declared-column-default.md b/.changeset/generator-declared-column-default.md deleted file mode 100644 index 831508a53d..0000000000 --- a/.changeset/generator-declared-column-default.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) - -## What was wrong - -Neither `os generate migration` format read a field's `defaultValue`, so a table -created from a generated migration had no column DEFAULT where the platform's -own table has one. A row inserted out of band — by a database client, a seed -script, anything that does not go through the engine — got NULL where the -declared value belonged. - -Driven on live PostgreSQL 16.13: one object, three schemas, one producer each -(`driver-sql` through `initObjects`, `--format sql` through `db.raw`, -`--format ts` by importing the emitted module and calling `up(db)`), with -`information_schema.columns` read back per schema. - -``` -field driver sqlgen verdict -f_default null=YES default='hello'::text null=YES default=- DIVERGED -f_default_required null=YES default='hello'::text null=YES default=- DIVERGED -``` - -After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one -covering every `defaultValue` shape. - -## What changed - -Both formats now render one shared verdict, taken from -`SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` -becomes DDL on the platform side: - -- a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not - `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in - `column_default`, and the driver's column carries the quoted form); -- **`'NOW()'`** becomes the driver's own translation, which is type-branched: - `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on - `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the - server's timezone; -- **any other runtime token** (`current_user`), an **Expression envelope** and - an **option-level `default: true`** emit nothing, each because the driver - emits nothing — the engine owns those, and a column DEFAULT would override a - decision it makes deliberately; -- a **`multiple: true`** field gets neither, because `createColumn` returns - before both questions. - -No authorable key, export or accepted-input set changes: `defaultValue` was -already declared, already parsed and already honoured by the driver. The -generators simply now read it. diff --git a/.changeset/grouping-field-non-padded.md b/.changeset/grouping-field-non-padded.md deleted file mode 100644 index e23b459755..0000000000 --- a/.changeset/grouping-field-non-padded.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. - -## What was wrong - -The padded name never failed anywhere. It failed to *group*. - -Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. - -That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. - -## What it does now - -`grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. - -⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | -| `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | - -The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. - -## Scope — what is deliberately NOT narrowed - -- **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. -- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. -- **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. - -## Who is affected, measured - -Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. - -Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. - -## Consumer - -**objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. diff --git a/.changeset/hono-adapter-declared-envelope-render.md b/.changeset/hono-adapter-declared-envelope-render.md deleted file mode 100644 index f6927fc8a5..0000000000 --- a/.changeset/hono-adapter-declared-envelope-render.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/plugin-hono-server': minor ---- - -fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) - -`HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / -`post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and -`IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher -bridge all mount. Until now a throw that escaped a route handler was answered -there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, -with the thrown value discarded — so a producer that had *declared* its refusal -lost both halves of the declaration on the way to the caller. - -The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises -`AuthzStoreUnavailableError` (declared `status: 503`, declared `code: -SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, -per the #13279 ruling that an unreadable store licenses no verdict. The operator's -outage reached the caller as a generic fault naming the wrong component: the -declared code never arrived, and the message said "No response from handler". - -**What changed.** An escaped throw carrying **both** a declared ADR-0112 status -(a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` -(`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, -with the producer's `details` and `userMessage` channels forwarded. The status -and code are read through `resolveThrownHttpError` — the one rule the REST -registrar and the dispatcher already share — so this seam agrees with the other -doors by construction rather than by a second ladder. - -**What did NOT change**, pinned in the same PR: - -- an escaped throw that is **not** such an envelope answers exactly the bytes it - answered before — 500, no cause in the body. A partial declaration (status but - no code, code but no status), an unregistered code, and a status ADR-0112 does - not declare all take that arm; -- a handler that simply wrote nothing is untouched; -- a handler that **wrote and then threw** keeps what it wrote; -- the `notFound` fallback seam still answers `Fallback handler failed` — a - fallback that threw is a broken consumer, not a refusal it declared; -- ⛔ no error code is minted and no ledger row is added. A code on this path that - is not registered is a ledger gap under the #16404 ruling, and takes the - unchanged 500 arm rather than being registered in passing. - -The 5xx disclosure filter every door emitting a thrown message already runs -(`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's -first day: a driver dump on a declared 5xx is withheld, where the old bare 500 -disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly -once at `error`, and now names the answer that was really sent instead of -claiming an opaque 500. - -⚠️ **Known-unreached door, stated rather than left silent.** A route mounted -through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, -so it is **not** repaired by this change and still answers a non-envelope -`text/plain` 500. That is out of this card's scope by the `domain:cli` seat's -ruling and is filed separately. diff --git a/.changeset/hook-input-is-the-persist-image.md b/.changeset/hook-input-is-the-persist-image.md deleted file mode 100644 index 52bcdb6727..0000000000 --- a/.changeset/hook-input-is-the-persist-image.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/plugin-auth": patch ---- - -fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) - - - -**BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. - -## The defect - -On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. - -Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: - -``` -read back: target_value 400 weight 10 ← the strip worked - score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" -``` - -The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. - -## What changed - -**`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. - -**The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. - -Two things deliberately did **not** move: - -- **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. -- **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. - -`@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. - -Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. - -## Who is affected - -A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: - -- **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. -- **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. -- **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. - -⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. - -A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. - -⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. - -An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. diff --git a/.changeset/hook-previous-row-invariant-rewrite.md b/.changeset/hook-previous-row-invariant-rewrite.md deleted file mode 100644 index 8b354b95ab..0000000000 --- a/.changeset/hook-previous-row-invariant-rewrite.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) - -The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** - -The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: - -- Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). -- What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. -- What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. -- Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. - -Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. diff --git a/.changeset/hook-withheld-readonly-key-diagnostic.md b/.changeset/hook-withheld-readonly-key-diagnostic.md deleted file mode 100644 index bd3a4ae012..0000000000 --- a/.changeset/hook-withheld-readonly-key-diagnostic.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) - -Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. - -**The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: - -``` -direct SandboxError: hook 'guard_task_body' threw: - TypeError: cannot set property 'who' of undefined -REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} -``` - -The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. - -### Who is affected - -Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. - -### What the message says now - -``` -A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is -`readonly: true`, and the engine withholds a caller-supplied value for a read-only field -from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the -platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` -instead. Original fault: TypeError: cannot set property 'who' of undefined -``` - -The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. - -### Deliberate limits - -No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. diff --git a/.changeset/hook-write-set-finding-path-lowered-handler.md b/.changeset/hook-write-set-finding-path-lowered-handler.md deleted file mode 100644 index 6b483879bb..0000000000 --- a/.changeset/hook-write-set-finding-path-lowered-handler.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/cli": patch ---- - -fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) - -`hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` -(`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / -`hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` -(`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, -because that is the shape they parse. For a hook authored as an inline -`handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), -`hooks[i].body` is not something the author wrote at all — `lowerCallables` -mints it from the handler before `os build` / `os lint` hand the stack to -these rules (#16095). The reported `path` therefore named a key that does not -exist in the author's own source file; grepping for `body.source` there finds -nothing. - -**What changed.** `lowerCallables` now records, per `lowerCallables()` call, -which `hooks[*].handler` ref strings got their `body` minted this way (as -opposed to a `body` the author wrote directly). The CLI's four lowering doors -(`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) -pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two -hook write-set rules use it to redirect a finding on a lowered hook to -`path: hooks[i].handler` — the key that replaced the function the author -wrote — with a message suffix ("judged on the metadata body lowered from the -inline handler") explaining why. A hook whose `body` the author wrote directly -is unaffected: `path` stays `hooks[i].body.source`, unchanged. - -**No verdict changed.** Which hooks are flagged, at what severity, and why is -untouched — #13653 and #4271 are unmoved by a word. Only the location a -finding points at, and the wording explaining it, are different. `os build` -and `os lint` continue to report the identical `path` and message for the -same hook (#16095's "one implementation, both commands agree" — now including -this). - -No `--json` field was added or removed: `path` and `message` keep their -existing shape (string), and this is a within-type value correction for the -one subclass whose old value could never be resolved against the author's -source in the first place. diff --git a/.changeset/host-importer-location-install-diagnostic.md b/.changeset/host-importer-location-install-diagnostic.md deleted file mode 100644 index e5c358a01d..0000000000 --- a/.changeset/host-importer-location-install-diagnostic.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/types": patch ---- - -`createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. - -A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. - -That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. - -Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. diff --git a/.changeset/i18n-check-platform-bucket-and-app-gating.md b/.changeset/i18n-check-platform-bucket-and-app-gating.md deleted file mode 100644 index bd1f096b9c..0000000000 --- a/.changeset/i18n-check-platform-bucket-and-app-gating.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) - -## What was wrong - -`collectExpectedEntries` walks the Studio metadata-form registries -unconditionally — identically for every config, an empty one included — so -every stack's expected set carries ~773 `metadataForms.*` keys that -`@objectstack/platform-objects` translates and the runtime already serves. - -Two of the three commands that see that family already knew it is not the -author's. `os lint` hides it and says so ("platform built-ins: 773 i18n -issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has -`--no-metadata-forms`. `os i18n check` is the one command that publishes a -**percentage**, and it carried the baseline in its denominator: - -``` -Coverage by locale - en ████████████████████████ 100.0% (1265/1265, missing 0) - zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) -``` - -That is an application with every key it owns translated. `--strict` and -`--threshold` — the two flags whose entire purpose is CI gating — therefore -could not gate an app package at all, and the only way to move the number was -to ship a copy of the platform's bundle, which would *override* the platform's -own and go stale at the next upgrade. The workaround was worse than the defect. - -## What it does now - -**Ownership is observed, not assumed.** The baseline counts toward coverage -when the stack under examination ships those translations itself, and does not -when it does not — read from the config's own `translations` bundles, requiring -a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a -claim of ownership. An app gets a number about its own surface with no flag; -`platform-objects`, which does ship the family, stays gated on it with no flag -either. An unconditional exclusion would have turned the app side green by -deleting the platform's own gate, and is what the negative-control tests forbid. - -**The flag is `os lint`'s, spelling and all.** `--include-platform` forces the -baseline in; `--no-include-platform` forces it out, for a package that ships a -partial baseline and does not intend to own the rest. Absent, the decision is -the observed one — three states, not two. - -**Both output faces carry the decision.** `--json` gains -`platformMetadataForms: { mode, excludedKeys }`, and the console prints -`platform built-ins: N key(s) not counted — rerun with --include-platform to -gate them here` under the coverage table, rendered from those same two numbers. - -`os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the -baseline by default, because lint folds it away one seam later and counts what -it folded for its own hint line. - -## Compatibility - -Additive on the command surface; an invocation that was refused is now -accepted, and no flag is removed or renamed. The behaviour that changes is the -**default coverage number for a stack that ships no `metadataForms` bundle** — -it stops reporting a debt that stack must not pay. A run that wants the old -numbers back asks for them with `--include-platform`, on the same argv. diff --git a/.changeset/i18n-inline-locale-map-population-count.md b/.changeset/i18n-inline-locale-map-population-count.md deleted file mode 100644 index a625886cf4..0000000000 --- a/.changeset/i18n-inline-locale-map-population-count.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. - -Two docblocks in this file each stated that the repo authors 31 inline locale maps — the -`INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across -three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real -authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages -author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in -`sys-organization.page.ts`, 6 in `sys-position.page.ts`. - -The number is **dropped** at both sites rather than corrected to 45. Neither sentence's -argument needs a magnitude. The first turns on the universal — *every* authored map uses those -four tags — so the accept set is what makes the constraint free, not the size of the set. The -second turns on the map being authored on published platform pages *and* resolved by -`pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime -ignores", so the count was never load-bearing there either. Writing 45 would buy one release of -accuracy in prose that is cited as evidence for a schema constraint, and the figure has already -drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is -keeping a number in a comment true. - -The measured half survives untouched at both sites: three platform pages author these maps, and -that is still exactly three. No schema arm, bound, default, `.describe()` string or export -changes; nothing an author can write is affected. diff --git a/.changeset/i18n-slotted-pages-and-global-filters.md b/.changeset/i18n-slotted-pages-and-global-filters.md deleted file mode 100644 index 261148b5c3..0000000000 --- a/.changeset/i18n-slotted-pages-and-global-filters.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor -"@objectstack/platform-objects": minor ---- - -Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). - -**BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. - -**`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. - -- Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. -- `translatePage` carries the rebuilt `slots` back onto the document. - -**`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. - -**`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. - -**`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. - -**Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. - - diff --git a/.changeset/id-field-retirement-declared.md b/.changeset/id-field-retirement-declared.md deleted file mode 100644 index 503f9c965e..0000000000 --- a/.changeset/id-field-retirement-declared.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). - -The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. - -**The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. - -That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. diff --git a/.changeset/import-protocol-implementor-typed.md b/.changeset/import-protocol-implementor-typed.md deleted file mode 100644 index 4cb5cf8fe0..0000000000 --- a/.changeset/import-protocol-implementor-typed.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) - -`admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. - -The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. - -`FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. - -No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. diff --git a/.changeset/import-protocol-typed-args.md b/.changeset/import-protocol-typed-args.md deleted file mode 100644 index ba2c88d924..0000000000 --- a/.changeset/import-protocol-typed-args.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) - -The exported extension point `runImport` accepts a protocol through now states its own contract. - -**FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: - -```ts -export interface ImportProtocolLike { - findData(args: any): Promise; - createData(args: any): Promise; - updateData(args: any): Promise; -} -``` - -**TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): - -```ts -export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; - -export interface ImportProtocolLike { - findData(args: ImportProtocolRequest): Promise; - createData(args: ImportProtocolRequest): Promise; - updateData(args: ImportProtocolRequest): Promise; -} -``` - -**Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. - -**Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: - -```ts -// before — compiles, and silently degrades to match-everything when `$filter` is absent -async findData(args: any) { - const where = args?.query?.$filter ?? {}; - const limit = args?.query?.$top ?? 2; -} - -// after — drop your own annotation and let the declaration type the parameter -async findData(args) { - const where = args.query!.where; - const limit = args.query!.limit; -} -``` - -⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. - -⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. - - diff --git a/.changeset/import-runner-canonical-query-ast.md b/.changeset/import-runner-canonical-query-ast.md deleted file mode 100644 index 06b036abfd..0000000000 --- a/.changeset/import-runner-canonical-query-ast.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. - -`FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. - -- **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. -- **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. -- **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. - -⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. diff --git a/.changeset/insert-check-post-image.md b/.changeset/insert-check-post-image.md deleted file mode 100644 index 33fd97c8e4..0000000000 --- a/.changeset/insert-check-post-image.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/objectql": minor ---- - -fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) - - - -**BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. - -`check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. - -A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: - -- **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. -- **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. - -Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. - -**What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. - -**Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. - -**Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: - -- a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. -- an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. - -**The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. - -⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. diff --git a/.changeset/iso-from-valid-date-family-collapse.md b/.changeset/iso-from-valid-date-family-collapse.md deleted file mode 100644 index e5ecf7c10a..0000000000 --- a/.changeset/iso-from-valid-date-family-collapse.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -"@objectstack/metadata": minor -"@objectstack/metadata-protocol": patch ---- - -fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) - -## What was wrong - -`#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside -the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a -valid JS `Date` becomes ISO text — and handed **every other input back -untouched**. Four adapter boundaries used it, and each fed a field declared -`z.string()` or `z.string().datetime()`: - -| site | declared as | -|:--|:--| -| `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | -| `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | -| `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | -| `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | - -So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a -field declared `string`, each wearing an `as string` / `as string | undefined` -cast that asserted the opposite. Measured over the seven inputs that -distinguish the two helpers, the declared schemas refused **21 of 35** produced -values. - -`recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which -none of the three available answers was legal — the visible text -`"Invalid Date"` fails the refinement, `undefined` fails the required field, and -the pass-through fed it the `Date` object, which fails both. - -## What it does now - -Those four sites read `canonicalIsoInstant`, whose return type **is** -`string | undefined`, so all four casts are deleted rather than restated. Both -sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen -per site, from the site's own declared schema: - -- `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the - branch an absent column already took. ⛔ No default is invented for a field the - schema lets be absent. -- `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named - `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a - `now` stamp is a plausible-looking recording instant nobody measured, and it - sorts a version recorded years ago to the top of a newest-first timeline. The - epoch invents no fact and sorts to the oldest end. It is also the answer the - sibling reader of this same `sys_metadata_history.recorded_at` column already - gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). - -Schema refusals over the same seven inputs: **21 → 8**. The eight that remain -are a `number` and an opaque object at four sites — shapes no driver is measured -to materialise for these columns. They now arrive as the declared *type* (a -string) that simply is not a valid datetime, so the producer's bug stays visible -instead of being papered over. - -## One behaviour change worth reading twice — and it is why this is `minor` - -`DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An -Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — -so a row with an unreadable `updated_at` and a good `created_at` published -`new Date()` as its `mtime`. It now folds to `undefined` one step earlier and -loses the `??`, so the row publishes its `created_at`: a stored instant in place -of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column -takes" that `#14078`'s own ruling text prescribes for the shape. - -⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies -`MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the -pre-existing pin asserted exactly that. So this one site is **not** the repair of -a violation — it is one legal published answer replaced by a different legal -published answer on a published read verb. Nothing was refused before and is -permitted now; a consumer simply receives a different instant. - -## Why the two levels differ - -- **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, - are the "repairing an implementation that silently violated its own already - published declared type" case: the values that changed there are ones - `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and - nothing a consumer legitimately received has moved. But this package also - carries `stat()`, and that site changes a **legal** published answer, which the - paragraph above measures. The level is per package, so the four repaired sites - ride along at `minor`. -- **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves - a legal published answer. `rowToEvent` only stops emitting values - `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a - field declared `z.string()`), and `listCommits` is byte-identical on all seven - probe inputs. - -⛔ No declared type narrowed, no export was added or removed (neither helper was -ever exported), and no envelope or accept set moved — so this is `minor` by the -changed-answer row, not a breaking change, and it carries no ADR-0087 -disposition. - -## What deliberately did NOT collapse - -`listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock -promises callers the RAW value back for a non-`Date`, and the shared spelling -rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the -response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently -drops a value that is on disk) and hand a `number` or an opaque object to the -commit-timeline sort as `String(value)` rather than verbatim. Measured, that site -is byte-identical on all seven inputs before and after this change. - -`SqlDriver`'s same-named helper is not part of this family at all: it takes -`Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, -and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. diff --git a/.changeset/link-finder-declared-location-axis.md b/.changeset/link-finder-declared-location-axis.md deleted file mode 100644 index adf64377a0..0000000000 --- a/.changeset/link-finder-declared-location-axis.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/types': minor ---- - -Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. - -The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. - -Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. - -This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. - -Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. diff --git a/.changeset/lint-injected-temporal-column-types.md b/.changeset/lint-injected-temporal-column-types.md deleted file mode 100644 index 3176ab8d5b..0000000000 --- a/.changeset/lint-injected-temporal-column-types.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) - -`@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. - -Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: - -| authored filter | before | after | -|:--|:--|:--| -| `close_date: 'last_30_days'` (authored `date`) | refused | refused | -| `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | -| `created_at: 'last_30_days'` | **silent** | refused | -| `created_at: { $eq: 'last_30_days' }` | **silent** | refused | -| `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | -| `stage: 'this_quarter'` (a `select` column) | silent | silent | - -The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. - -## What changed - -`GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. - -`id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. - -**Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. - -**Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. - -## Two more rules inherit it, in the same edit - -The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: - -- **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. -- **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). - -`id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. - -A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. diff --git a/.changeset/listview-calendar-type-axis-scope-16577.md b/.changeset/listview-calendar-type-axis-scope-16577.md deleted file mode 100644 index d36781eb61..0000000000 --- a/.changeset/listview-calendar-type-axis-scope-16577.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) - -`checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. - -**No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. - -What the docblock now records, all of it measured rather than inferred: - -- The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. -- `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. - -⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. diff --git a/.changeset/lookup-picker-reader-prose-remeasured.md b/.changeset/lookup-picker-reader-prose-remeasured.md deleted file mode 100644 index f8f6252451..0000000000 --- a/.changeset/lookup-picker-reader-prose-remeasured.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. - -Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. - -- **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. -- **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. -- **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. - -Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. diff --git a/.changeset/lookup-picker-reference-only.md b/.changeset/lookup-picker-reference-only.md deleted file mode 100644 index a290848a68..0000000000 --- a/.changeset/lookup-picker-reference-only.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route -`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key -`reference` alone. The three tolerant fallback arms it used to read after it — the -`referenceTo`, `target` and `options.objectName` spellings — are deleted. - -Effect on the wire: a stored object-metadata row whose lookup field carries one of those -three spellings and no `reference` used to answer `200` with rows from the aliased object; it -now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field -carrying `reference` is unaffected, including a partially-migrated row carrying a legacy -spelling beside it. `publicPicker.object` on the form is still the explicit override and is -still read first. - -No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses -`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering -with a rename hint naming the canonical key, so no authoring path can produce such a row; a -census across both trees found no producer and no relation field carrying any of them, with -positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to -preserve. The spec spelling is the contract, and a stored row spelling the target the old way -is a producer defect rather than a dialect this route accommodates. - - diff --git a/.changeset/lookup-reference-target-gate.md b/.changeset/lookup-reference-target-gate.md deleted file mode 100644 index 53ef53a1bd..0000000000 --- a/.changeset/lookup-reference-target-gate.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/cli': patch ---- - -`object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` - -`FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. - -The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: - -1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; -2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; -3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; -4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. - -Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). - -## Migration - -**A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. - -**A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. - -**A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. diff --git a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md b/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md deleted file mode 100644 index cb38774326..0000000000 --- a/.changeset/mcp-readme-custom-connector-reaches-from-anthropic.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) - -`packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. - -The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: - -- **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. -- **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. - -Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. diff --git a/.changeset/mcp-refuse-undeclared-tool-arguments.md b/.changeset/mcp-refuse-undeclared-tool-arguments.md deleted file mode 100644 index 7e82ab200d..0000000000 --- a/.changeset/mcp-refuse-undeclared-tool-arguments.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them - -`query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` -and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` -with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached -through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not -report what it never received. Nothing in either payload distinguished it from a real answer, and the -consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer -confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a -WIDER one. - -All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict -object, so an undeclared key is refused before dispatch, the data bridge is never reached, and -`tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema -rather than discoverable only by being refused. The refusal names the offending key and, where the -spelling is recognisable, the declared one to send instead. - -Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already -inert: it was dropped, and the call proceeded exactly as if it had never been sent. - -| previously sent and ignored | send instead | on | -| :-- | :-- | :-- | -| `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | -| `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | -| `select`, `columns`, `projection` | `fields` | `query_records` | -| `pageSize`, `top`, `take` | `limit` | `query_records` | -| `skip`, `start` | `offset` | `query_records` | -| `filters`, `filter`, `conditions` | `where` | `aggregate_records` | -| `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | -| `group_by` | `groupBy` | `aggregate_records` | -| `tz`, `timeZone` | `timezone` | `aggregate_records` | -| `object`, `table` | `objectName` | every object-scoped tool | -| `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | -| `record`, `values`, `fields` | `data` | `create_record`, `update_record` | -| `action`, `name`, `action_name` | `actionName` | `run_action` | -| `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | -| `formula`, `expr`, `cel` | `expression` | `validate_expression` | - -A key outside this table is refused with its name echoed back and a closest-declared-key suggestion -when one is within a length-relative edit distance. diff --git a/.changeset/mcp-token-human-principal.md b/.changeset/mcp-token-human-principal.md deleted file mode 100644 index aa4ce21770..0000000000 --- a/.changeset/mcp-token-human-principal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token - -`AuthManager.verifyMcpAccessToken` resolved an M2M access token to a -principal — a machine ran as an authenticated member, stamping a user id that -belongs to no user into `created_by` / `updated_by` and owner columns — while -the method's own contract declared such tokens rejected. The contract's -premise was that they carry no `sub`; the OAuth provider stamps -`sub = user?.id ?? client.clientId`, so the premise was never true and the -rejection it described could never fire. - -The subject and the client identity are now read as a pair, the way RFC 9068 -defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` -is the resource owner for a grant that had one or an identifier for the client -application for a grant that did not (§2.2.3.1). A token whose `sub` equals its -own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door -answers `401`. A token carrying neither client claim is refused as well: the -check has no input, and a check that cannot run must not silently pass. - -Unchanged: interactive OAuth clients (authorization code + PKCE) resolve -exactly as before, and the headless track is untouched — `x-api-key` / -`Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate -chain with a separate credential shape, and remain the supported way for a -machine to call this platform. diff --git a/.changeset/memory-driver-tenant-scope-refusal.md b/.changeset/memory-driver-tenant-scope-refusal.md deleted file mode 100644 index f3374541d0..0000000000 --- a/.changeset/memory-driver-tenant-scope-refusal.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) - -**BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. - -Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. - -This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. - -**⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. - -What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. - -The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. - -Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. - - diff --git a/.changeset/memory-matcher-scalar-comparand-array-value.md b/.changeset/memory-matcher-scalar-comparand-array-value.md deleted file mode 100644 index dd0926535c..0000000000 --- a/.changeset/memory-matcher-scalar-comparand-array-value.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/driver-memory": minor ---- - -fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) - -`memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: - -| filter | stored value | reference matcher, before | live query path | -|---|---|---|---| -| `{ tags: 'a' }` | `['a','b']` | no row | the row | -| `{ tags: 'a,b' }` | `['a','b']` | the row | no row | -| `{ tags: 'a' }` | `['a']` | the row | the row | - -The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". - -**What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. - -**What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. diff --git a/.changeset/memory-unique-sticky-tenancy-opt-out.md b/.changeset/memory-unique-sticky-tenancy-opt-out.md deleted file mode 100644 index d877f97e20..0000000000 --- a/.changeset/memory-unique-sticky-tenancy-opt-out.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/driver-memory": minor -"@objectstack/driver-sql": patch -"@objectstack/objectql": patch ---- - -fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) - -## What was wrong - -`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever -schema THAT call happened to carry. A second registration without a `tenancy` -block — the `{ name, fields }` shape — fell through to the implicit -`organization_id` heuristic, so a `unique` field moved from **one row per -install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) -to **one row per organization**. A duplicate the declaration refuses then -landed. Measured at the driver door on `origin/main` `d61139f1ba`: - -| sequence | second `key: 'K'`, different organization | -|:--|:--| -| register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | -| …then re-register with `{ name, fields }` | **`LANDED`** | - -`SqlDriver` running the same sequence refuses in **both** cases: it has kept a -sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner -`computeTenantField` and not the wrapper that consults the record, so "mirrors -`computeTenantField` arm for arm" stayed literally true while the pair diverged. - -It is silent in both directions — nothing logs the flip, and the refusal names -the field, never the partition. That is the declared-vs-enforced shape Prime -Directive #10 forbids, reached by a state change rather than by a missing check. - -## What it does now - -- **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the - sticky resolver, and the `TenantOptOutRecord` type for the per-instance record - a driver owns. `InMemoryDriver` holds one and resolves through it, handing - BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — - the same resolved column. `uniqueConstraintsFromFields` and - `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional - second argument; called with one argument they answer exactly as before. - `tenantFieldOf` is unchanged and still a pure function of its argument. -- **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with - the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` - block gave a shard an organization key part the base table's index does not - have — one object, two partitions, decided by which physical table a row - landed in. It now resolves through the record, keyed by the base table. -- **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The - Archiver hands that object straight to `cold.syncSchema`, and the published - type refused the key while the driver below read it — so an author writing a - fresh literal was pushed into producing exactly the partial re-registration - above. Same correction #16711 made where the shard leaf narrowed the key off - the object it was handed. - -The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a -declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object -that never declared the opt-out never enters the record, so a genuinely -org-scoped object keeps its `organization_id` partition across a partial -re-registration — an implementation answering `null` more often would not be -stickier, it would be tenant isolation switched off. A carried `tenancy` block -stays authoritative in both directions and CLEARS a recorded opt-out. - -`@objectstack/driver-memory` is `minor` for the two new public-entry exports. -The behaviour repairs themselves are `patch`: each restores an implementation to -the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was -already declaring, rather than replacing one legal published answer with -another. The `objectql` entry is a published type WIDENING — a key the interface -refused is now accepted, and nothing that compiled before stops compiling. diff --git a/.changeset/meta-state-route-engine-outage-distinguishable.md b/.changeset/meta-state-route-engine-outage-distinguishable.md deleted file mode 100644 index af9945d70d..0000000000 --- a/.changeset/meta-state-route-engine-outage-distinguishable.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) - -`objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. - -That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. - -**What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. - -**What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. - -**Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. - -`POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. diff --git a/.changeset/meta-types-action-schema-no-longer-empty.md b/.changeset/meta-types-action-schema-no-longer-empty.md deleted file mode 100644 index 4b3b378440..0000000000 --- a/.changeset/meta-types-action-schema-no-longer-empty.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Fix `GET /meta/types` serving an empty JSON Schema for `action` - -`ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no -properties, so `/meta/types` advertised `action` as -`{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that -reads as "this type declares no constraints" for a type that accepts 47 keys. -The hand-crafted fallback declared for this case never fired, because the -conversion did not throw: it succeeded and returned a truthy husk, which -short-circuits the `??` that was supposed to reach the fallback. - -A derivation that comes back with no properties, no union arms, no `$ref` and no -`additionalProperties` object is now treated as a non-answer. It is retried in -the authoring shape (`io: 'input'`), and if that degenerates too the type is -named in a one-shot warning and the hand-crafted fallback decides. - -Only `action` changes. The `output` derivation remains the served default on -purpose: deriving every type with `io: 'input'` was measured across the whole -served surface and would move 24 of the 26 types that carry a Zod schema, in the -direction of a weaker contract (`required` entries 1132 to 867, -`additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps -the change to the one type that was actually broken. - -Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now -receive its real 47 properties instead of an empty object. No other type's -served payload moves, and a type that resolves no Zod schema at all continues to -be served with no schema — absence is not the same failure as a derivation that -came back empty. diff --git a/.changeset/migrate-meta-default-range-terminus.md b/.changeset/migrate-meta-default-range-terminus.md deleted file mode 100644 index 2ef119d6d7..0000000000 --- a/.changeset/migrate-meta-default-range-terminus.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) - -`--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: - -``` -✓ Nothing to migrate — the metadata is already canonical for this range. -``` - -exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** - -Two changes, both in `packages/cli`: - -- **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. -- **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. - -**What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. diff --git a/.changeset/migrate-meta-protocol-version-key.md b/.changeset/migrate-meta-protocol-version-key.md deleted file mode 100644 index a2fa561249..0000000000 --- a/.changeset/migrate-meta-protocol-version-key.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/metadata-core": minor ---- - - - -feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) - -**BREAKING** — two published machine surfaces change a key name. There is **no alias -and no dual-key transition window**: one axis, one name. - -| Surface | Was | Now | -|:--|:--|:--| -| `os migrate meta --json` payload | `runtime` | `protocolVersion` | -| `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | -| `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | - -The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol -major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload -moves — no other key is added, removed or reshaped, and both text faces are byte-identical. -The parameter rename is positional, so no call site changes. - -## Why the name had to move - -`PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed -`@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* -it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or -a stale install, next to the real package versions of the same upgrade session. - -The human line was repaired first and now reads -`Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the -worse half and was left standing, because a key on a published payload is a contract -change: an agent scripting an upgrade has no prose to disambiguate at all, and the -diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer -does not parse. - -## What a consumer should do - -Read the new key. The old one is absent, so a consumer that does not move reads -`undefined` rather than a wrong value. - -```diff -- const v = payload.runtime; // os migrate meta --json -+ const v = payload.protocolVersion; - -- const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE -+ const v = err.diagnostic.protocolVersion; -``` - -The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` -spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it -from the package install boundary, and `@objectstack/services-package` reads it during -hydration — so a consumer reading it from any of those reads the new name too. - -`runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer -protocol major, not a semver in a version position, and it does not carry the ambiguity -this rename closes. - -The breaking surface was measured before the rename and is closed inside this repository: -the only reader of the `--json` key was this repo's own e2e pin and the only reader of the -diagnostic member was `metadata-core`'s own unit test, both of which move in this same -change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever -naming the field. **Zero external consumers were found.** Graded `minor` rather than -`major` for the launch window; the banner above carries the breaking-ness the level cannot. diff --git a/.changeset/nested-strand-chain-restore.md b/.changeset/nested-strand-chain-restore.md deleted file mode 100644 index 7da61e9c18..0000000000 --- a/.changeset/nested-strand-chain-restore.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/plugin-approvals': patch ---- - -`restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit - -`resumeInternal`'s catch arm journalled the consumed suspension of the run that -threw, and nothing else. For a nested run the ancestors were handled on both -paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` -and calls `failSuspendedRun` on each suspended ancestor) and delegation (the -parent frame sees a failed child with no retryable code and calls -`failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, -'failed')` plus a `failed` log record — it journalled nothing. - -So the leaf was restorable while every ancestor was recorded `failed` with its -pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered -`NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent -that never continues: `bubbleToParent` found no parent suspension and logged. -The operator ended up worse off than before using the exit. - -`failSuspendedRun` now journals the pause it consumes whenever the descendant -whose failure consumed it is itself repairable — from the same single producer -and onto the same durable terminal row as the strand's own snapshot, so the -chain is repairable from any replica and after a restart, not only from the -process that stranded it. `restoreConsumedSuspension` then repairs the chain as -one unit: it walks down to the stranded descendant and up through the ancestors -it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes -resumable only after the run it is parked awaiting is parked again. The entry -point does not matter — naming any member of the chain repairs all of it — and -the continuation is then re-issued once, on the run that was named. - -Additive on the wire and in the type: the result's existing fields still -describe the run the caller named, and the new `chain` key is present only when -the repair was a chain repair. `ChainRestoreEntry` is exported for it. The -narrower `IAutomationService.restoreConsumedSuspension` contract in -`@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the -door answers `{ runId, restored, reason }` as it always did. - -Every member goes through the same per-run call as a flat restore — its own -in-process claim, its own strict live-suspension read, its own two-witness read, -its own durable park — so idempotence and the #14333 advance claim hold per run -in the chain: a second restore finds every member parked and answers -`RUN_SUSPENDED` without minting a second pause anywhere. - -⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run -that consumed its OWN pause and then threw downstream, and nothing re-arms an -ancestor by resuming it; stamping it would send an operator to retry a recovery -that cannot succeed. The parent frame's delegation result still carries no -status at all, and an ancestor's repairability is carried by the journal and by -this verb's answer. - -Journalling is EARNED, not applied to every cascade: an ancestor whose -descendant is beyond repair is still consumed without a snapshot, because -re-arming it would promise a chain repair that could not be completed. - -**`@objectstack/plugin-approvals`** reports the consequence rather than causing -it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed -ancestor whose descendant is repairable now comes back `runState: -'repairable'` instead of `'unrepairable'`, and restoring either row repairs the -pair. `'unrepairable'` keeps its other causes — a run that never paused, a -snapshot no longer held, and a cascade whose descendant was itself beyond -repair. No plugin logic changed; the docblocks that documented the old -limitation did. diff --git a/.changeset/notify-zero-delivery-is-distinguishable.md b/.changeset/notify-zero-delivery-is-distinguishable.md deleted file mode 100644 index dfd6d00a27..0000000000 --- a/.changeset/notify-zero-delivery-is-distinguishable.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -`notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify - -A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. - -`emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. - -The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. - -The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. - -`selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. diff --git a/.changeset/numeric-column-representation.md b/.changeset/numeric-column-representation.md deleted file mode 100644 index 719018cda5..0000000000 --- a/.changeset/numeric-column-representation.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-sql': minor -'@objectstack/cli': minor ---- - -One physical representation for the NUMERIC column family, read by every producer of DDL - -`packages/spec` now states, per field type, what column a numeric field gets, and all three -producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and -`os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object -through all three producers, before and after: - -``` - BEFORE AFTER - driver sql gen ts gen all three -number real numeric(18,2) numeric(8,2) numeric(65,30) -currency real numeric(18,2) numeric(8,2) numeric(65,30) -percent real numeric(5,2) numeric(8,2) numeric(65,30) -slider real numeric(18,2) numeric(8,2) numeric(65,30) -summary real numeric(18,2) numeric(8,2) numeric(65,30) -progress real numeric(5,2) numeric(8,2) numeric(65,30) -rating real integer integer integer -``` - -7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own -direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; -`numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round -half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is -MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only -candidate measured to lose nothing on a nine-value corpus. - -Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from -`required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is -the write-time contract the record validator enforces, and binding the DDL to it made every -post-deploy tightening a destructive migration. - -**BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no -backfill runs. Four consequences to know before creating new tables: - -- `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count - DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a - `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no - error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal - set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` - as a REAL in an INTEGER-affinity column, unchanged from today. -- An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 - fractional digits: a magnitude whose significant digits run past the 30th decimal place loses - the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so - the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 - are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; - the rounding it replaces was not. -- Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number - (`z.number().finite()`), so a value that was never a JS double does not survive the round trip - exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. - The fidelity this buys is an exact COLUMN read through a double: values written by this - platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any - magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract - change and is not in this release. -- A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. - Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's - own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT - supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on - 2026-09-08). A source author who wants the column they had must write that block themselves; - `required: true` keeps its own meaning, the write-time contract the record validator enforces. - -SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both -`table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. - - diff --git a/.changeset/oauth-agent-runs-as-the-user.md b/.changeset/oauth-agent-runs-as-the-user.md deleted file mode 100644 index 4c945367bb..0000000000 --- a/.changeset/oauth-agent-runs-as-the-user.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": minor -"@objectstack/mcp": minor -"@objectstack/runtime": minor ---- - -fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) - -Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** - -**The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: - -| identity path | `crm_account` | `crm_opportunity` | `crm_task` | -|:--|--:|--:|--:| -| API key, `principalKind: human` | 9 | 23 | 45 | -| OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | - -The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. - -**The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. - -**(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. - -⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. - -**(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. - -**(3)** The Setup page's promise is untouched — it is now true rather than rewritten. - -Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. - -`DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: - -| direction, after release | consumer cost | -|:--|:--| -| ship optional fields, later tighten them to required | a compile break | -| ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | - -The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. diff --git a/.changeset/oauth-register-declares-only-honoured-members.md b/.changeset/oauth-register-declares-only-honoured-members.md deleted file mode 100644 index cf48a99420..0000000000 --- a/.changeset/oauth-register-declares-only-honoured-members.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/client": minor ---- - -fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) - -**BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. - -## What a caller passing these members should do instead - -| you were passing | pass instead | why | -|---|---|---| -| `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | -| `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | -| `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | - -## ⚠️ These were the vendor's RECORD vocabulary, not typos - -`client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. - -## Why they had to go rather than be honoured here - -`POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. - -A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. - -Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. - -Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. - - diff --git a/.changeset/object-block-sort-item-array.md b/.changeset/object-block-sort-item-array.md deleted file mode 100644 index 4e09034f60..0000000000 --- a/.changeset/object-block-sort-item-array.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) - - - -**BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` -and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the -repo's launch-window convention for breaking changes; the migration prescription -is registered under protocol major 18 as `object-block-sort-item-array`. - -One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, -2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is -objectui PR #8758, which drops the legacy string arm from -`convertSortToQueryParams`). Item 4 of that ruling is this release's subject: -「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the -`sort` value to the array shape (today it accepts anything), so the spec, the -registrations and the helper agree; that is a pull-back to the declared contract, -ordinary tier」. - -Until this release both doors declared `z.unknown()` — no orthography at all. -Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the -change: an array, the legacy string clause and a bare NUMBER all returned -`success: true`, while `bogusProp` was refused by name on the same call. So key -checking was live and only the VALUE was unheld, and an author following -objectui's own registrations (`plugin-grid/src/index.tsx:222` has published -`type: 'array'` all along) and an author following the legacy string each got a -silent success receipt for a different shape — while objectui's html tier -answered `type-mismatch` on the second one. Both doors now declare -`z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, -`ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already -carry: one shared schema, not a third copy. - -Sequenced measurement-first, as this family has to be. At the objectui pin this -repo builds against (`53ded82b`) the string is still lowered — -`ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto -`$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` -to `convertSortToQueryParams`, whose string arm is still present at -`sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned -consumer, which the ruling permits explicitly — either order, since the -registrations already declare the array — and the next pin bump carries the -retirement in. - -**Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes -`sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name -`sort: 'created_at'` meant ascending and becomes -`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in -`SortItemSchema`, so it is written out rather than omitted; a comma-separated -clause becomes one array entry per key, in the same order. The string is refused -at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or -absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten -and this disposition adds no D2 conversion — a stored page carrying a string -`sort` keeps loading and still renders at the pinned `.objectui-sha`; what -changes is that RE-SAVING it is refused at the `sort` door. - -**Not moved by this release.** `record:related_list.sort` keeps its declared -string arm: that string is the `'field'` / `'-field'` dialect read by -`RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and -retiring it was not ruled — objectui#8221's own implementing round narrowed it, -established the dialect and reverted the narrowing byte-identically. -`object-grid.defaultSort` is a different key, already retired by #11805. Zero -authored `sort` values on either block exist in this repo (the two showcase pages -that author `object-grid` declare none), so nothing in-tree was converted. - -Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither -block's parsed state moves for this key, and both already take the -`…PropsParsed` route for `filter` (ADR-0122). diff --git a/.changeset/objectql-aggregate-inmemory-rows-ast.md b/.changeset/objectql-aggregate-inmemory-rows-ast.md deleted file mode 100644 index 0075d356ac..0000000000 --- a/.changeset/objectql-aggregate-inmemory-rows-ast.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) - -`engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. - -`find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: - -- the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; -- a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. - -The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. - -`patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. diff --git a/.changeset/objectql-scoped-repository-declared-returns.md b/.changeset/objectql-scoped-repository-declared-returns.md deleted file mode 100644 index 0cd2a59e90..0000000000 --- a/.changeset/objectql-scoped-repository-declared-returns.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/objectql": minor ---- - -feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) - -**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). - -`ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: - -- `findOne` → `Promise | null>` -- `update` → `Promise | number | null>` - -`IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. - -A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. - -`updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. - - diff --git a/.changeset/one-app-rule-adr-0019-citation.md b/.changeset/one-app-rule-adr-0019-citation.md deleted file mode 100644 index 233a4f248b..0000000000 --- a/.changeset/one-app-rule-adr-0019-citation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` - -`ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. - -The three citations of the app-cap rule now name the record: - -- the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; -- the `validateSingleApp` docblock; -- the `StackSingleAppViolationError` docblock. - -Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. - -The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. diff --git a/.changeset/operator-facing-raw-exec-cause-text.md b/.changeset/operator-facing-raw-exec-cause-text.md deleted file mode 100644 index d9f5558e19..0000000000 --- a/.changeset/operator-facing-raw-exec-cause-text.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/metadata-protocol': patch -'@objectstack/metadata': patch -'@objectstack/cli': patch -'@objectstack/driver-sql': patch ---- - -fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal - -Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer -lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a -COMPOSED message that discloses neither the statement nor the diagnostic, and carries -the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and -is unchanged here. - -What changed underneath it is what every consumer STORED. Each migration probe, backfill -and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded -`error.message` into an operator-facing record, so those records began reading - - the database refused to run a raw statement - -where they used to read - - no such column: foo - -For a live console that costs nothing — the driver prints the statement and the dialect -text to its warn sink one line earlier. For a record read later it costs everything: -whoever opens a customer install's backfill result a week on never had that line, and the -dialect's words were unrecoverable for them. - -`@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk -of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen -stored-record sites plus `os db clean`'s console line read through it: - -- `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; -- `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the - three per-object warnings; -- `partial-index-probe` — the `detail` both callers report (and its two module comments, - which stated the opposite of what happened); -- `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, - `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; -- `os db clean` — the `VACUUM failed` line. - -Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned -on its own message channel, its `cause` never walked, and a declared envelope that is not -the raw-path one — the typed read exits' terminal, which composes a different sentence — -is left exactly as it arrived. - -That message channel is deliberately NOT byte-identical to what the replaced expressions -computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as -`messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the -string itself when a string was thrown, and `String(error)` when neither yields text. Every -difference from the replaced expressions follows from that rule, so read the rule and not a -list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, -which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a -thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read -`undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no -record was written at all and the operation aborted; an object carrying a NON-EMPTY string -`message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` -(one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads -`''`, so this channel is neither always prose nor never empty. - -## The levels, and why they are not uniform - -`@objectstack/types` takes **`minor`**: it is the one package here that grows a published -surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the -export list. A purely additive widening takes at least `minor`. - -The other four take **`patch`**, because none of them widens anything: they are a bug fix in a -released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named -because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper -against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this -PR: no entry point reaches a test file, and `files` packs `dist` only. - -**Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: -what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name -and never a type. The change these sites were made for is the declared raw-path fault, where -the record gains the dialect's words in place of the driver's composed placeholder. Every -other throw now reaches these records through the rule above rather than through the -expression each site spelled out, so its text can move too — a consequence of the rule, not a -bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, -and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an -`Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is -`seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on -top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; -that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and -whether it should go is tracked by #17167. The sentence being replaced is not a value any -consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these -records gets the dialect's words back where it had been getting a placeholder. diff --git a/.changeset/organizations-open-core-prose.md b/.changeset/organizations-open-core-prose.md deleted file mode 100644 index 55844b53e8..0000000000 --- a/.changeset/organizations-open-core-prose.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/plugin-dev': patch ---- - -Operator-facing text no longer tells an open-source install that multi-organization -operation requires a subscription. - -ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` -is Apache-2.0, carries no licence check, and declares both walled postures (`group` and -`isolated`) as its own constant. The messages an operator actually reads had not followed: - -- `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and - is NOT on the public npm registry ... Without one this bullet is not followable" — it now - says the runtime is Apache-2.0 and on the public registry, and notes that a commercial - deployment resolves the same package name to its own private, licence-gated build. -- The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the - runtime "enterprise". -- `os verify`'s `--org-scoped` flag description drops the same word. -- The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe - the package as the enterprise runtime. - -Text only — no control flow, no identifiers, no behaviour change. diff --git a/.changeset/osv-advisory-bumps-2026-09.md b/.changeset/osv-advisory-bumps-2026-09.md deleted file mode 100644 index af74c7d9a4..0000000000 --- a/.changeset/osv-advisory-bumps-2026-09.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/plugin-email": patch -"@objectstack/plugin-hono-server": patch ---- - -Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. - -The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. - -Two published packages change what a downstream install resolves, which is what this changeset grades: - -- **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. -- **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). - -No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. - -The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). - -`hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. diff --git a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md b/.changeset/page-guidance-stops-prescribing-assignedprofiles.md deleted file mode 100644 index a7bec10c9c..0000000000 --- a/.changeset/page-guidance-stops-prescribing-assignedprofiles.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) - -The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. - -Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. - -Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. - -**Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. diff --git a/.changeset/permissions-alias-hosts-justification.md b/.changeset/permissions-alias-hosts-justification.md deleted file mode 100644 index 83235a96ae..0000000000 --- a/.changeset/permissions-alias-hosts-justification.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. - -`PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. - -The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. - -The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. - -`hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. diff --git a/.changeset/persist-terminal-run-status-distinction.md b/.changeset/persist-terminal-run-status-distinction.md deleted file mode 100644 index f45dc36989..0000000000 --- a/.changeset/persist-terminal-run-status-distinction.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. - -`RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. - -- **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. -- **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. -- **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). -- **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. -- **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. - -Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. diff --git a/.changeset/plain-donkeys-repeat.md b/.changeset/plain-donkeys-repeat.md deleted file mode 100644 index ee8dfe6a85..0000000000 --- a/.changeset/plain-donkeys-repeat.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare the ASSEMBLED manifest stage on the installed-package read API. - -`GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a -package was installed with, and two stages reach that table through declared -doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` -= glob patterns), while a `defineStack()` host installs the assembled body -(`manifest.objects` = object definitions). Both response schemas typed every row -at the authoring stage alone, so the shipped `defineStack()` path served a -payload its own declared contract refused. - -Following the #14242 ruling — declare the assembled stage rather than widen the -authoring one — `@objectstack/spec/api` gains two exports: -`AssembledInstalledPackageSchema` (the assembled-stage counterpart of -`InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union -over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` -and `GetInstalledPackageResponseSchema` are bound to the union. - -This is additive at runtime, and the runtime parse is where the gain is: every -payload that parsed before still parses, payloads that were refused for their -manifest stage now parse, and a row belonging to neither stage — an `objects` -array mixing globs with definitions — is still refused. `ManifestSchema` is -unchanged. - -The STATIC gain is one-sided, and smaller than a union normally implies. -`AssembledPackageBodySchema` is annotated `z.ZodType, …>` -in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded -there, and untouched by this change — so the assembled branch carries no field -typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off -one of these two response types now yields `unknown` where it used to yield -`string`; narrowing toward the AUTHORING branch restores the whole of -`ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away -from it yields `Record` — every manifest field `unknown`. In the -assignment direction the assembled branch admits any object at `manifest`, so a -garbage manifest and the mixed-stage row named above both typecheck clean even -though the runtime union refuses both. So: narrow at the point of use for the -authoring stage, and treat an assembled manifest as a record the runtime — not -the compiler — has checked. - -`@objectstack/spec/api` also gains a `browser` export condition. Declaring the -assembled stage makes this entry's module graph reach the datasource -declaration and with it the driver-config validators, whose postgres URL -refinement links `pg-connection-string` — a package whose `parse` statically -resolves `require('fs')`, so a browser bundler that reaches it fails on -`Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a -build with the pg-grammar arm swapped for its dependency-free twin: exactly the -boundary the four entries that already carry the condition use. Node resolution -and the Node bundles are unchanged, byte for byte. For browser consumers the -postgres `url` refinement degrades to the shape-only checks it already performs -before `parse` — the unix-socket short-circuit and the refusal of the -filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters -are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource -publish is a server-side act, so that arm never legitimately ran in a browser. diff --git a/.changeset/platform-admin-existing-holder-scan.md b/.changeset/platform-admin-existing-holder-scan.md deleted file mode 100644 index 18e204a702..0000000000 --- a/.changeset/platform-admin-existing-holder-scan.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. - -Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. - -That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. - -- **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. -- **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. -- **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). -- **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. -- **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. - -Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. diff --git a/.changeset/platform-admin-promotion-selection.md b/.changeset/platform-admin-promotion-selection.md deleted file mode 100644 index 8095c9e08a..0000000000 --- a/.changeset/platform-admin-promotion-selection.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. - -Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. - -- **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. -- **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. -- **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. -- **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. -- **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. -- **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. - -Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. diff --git a/.changeset/plugin-auth-admin-import-canonical-query.md b/.changeset/plugin-auth-admin-import-canonical-query.md deleted file mode 100644 index ad0377e53d..0000000000 --- a/.changeset/plugin-auth-admin-import-canonical-query.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -`runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. - -`POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. - -- **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. -- **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. - -⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. diff --git a/.changeset/plugin-security-read-fault-vs-empty.md b/.changeset/plugin-security-read-fault-vs-empty.md deleted file mode 100644 index 8654b2d30f..0000000000 --- a/.changeset/plugin-security-read-fault-vs-empty.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). - -`reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. - -`normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. - -⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. - -No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. diff --git a/.changeset/plugin-version-honest-grammar-claim.md b/.changeset/plugin-version-honest-grammar-claim.md deleted file mode 100644 index 3f7e059ef2..0000000000 --- a/.changeset/plugin-version-honest-grammar-claim.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. - -The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: - -| SemVer 2.0.0 rule | Strings this key accepts anyway | -|---|---| -| §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | -| §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | -| §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | - -**No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. - -The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". - -**What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. - -All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. - -`@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. diff --git a/.changeset/plugin-version-semver-grammar.md b/.changeset/plugin-version-semver-grammar.md deleted file mode 100644 index 4cd3a9306a..0000000000 --- a/.changeset/plugin-version-semver-grammar.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor ---- - -`PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. - -Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: - -| Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | -|---|---|---| -| `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | -| `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | - -SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. - -**The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. - -**`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. - -**`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: - -- **`id`** — a non-string, or the empty string. -- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. -- **`staticPath`** — a non-string. -- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. -- **`default`** — a non-boolean. -- **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** -- **`description`** — a non-string. -- **`author`** — a non-string. -- **`homepage`** — a non-string, or a string that is not a URL. - -**`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. - -⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. - -**What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. - -**What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. - -⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. - -**Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. - - diff --git a/.changeset/preview-avg-empty-group-null.md b/.changeset/preview-avg-empty-group-null.md deleted file mode 100644 index c9c989a2d4..0000000000 --- a/.changeset/preview-avg-empty-group-null.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face - -A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube -metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built -its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` -and `Number.isFinite` accepts it, so every NULL entered the average as a zero -OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null -values in every SQL dialect, so a drafted chart showed a different number than -the published one, silently — and where a group's column was NULL in every row -the number it showed was `0`: a plausible-looking average that a reader cannot -tell from one somebody measured. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered -`avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, -`{travel, null}` answered 15 live and 10 on preview. Both cells now answer the -live number. - -The empty answer is READ from the platform's own ruling rather than restated -here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` -where counting or summing nothing is a measured fact and `undefined` — spelled -`null` on this wire — where there is nothing to answer. It is the same function -`fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited -when it moved `min`/`max` off the same idiom in this function. - -Unchanged, and pinned by the same differential: `sum` over a group with no values -still answers the ruled identity `0`, `count` over one still answers `0` -(#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that -has values still answers its mean. `sum` and the numeric `default` arm keep their -existing operand list — `0` is the additive identity, so the coercion never moved -`sum`'s answer, and the `default` arm serves the custom-SQL metric types, which -have no live standard to be moved towards. - -The `null` fires on an EMPTY group and never on an incoherent one. "No numeric -operand" is two different situations: no row carried a value at all — the empty -group the policy rules on — or rows carried values that do not read as numbers, -such as a `date` column under `avg`. The second is an incoherent -aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the -numeric identity it has always had, since the live face answers a different -number again (SQLite's numeric affinity over a TEXT column) and a `null` there -would invent a third answer. That boundary is pinned from both sides — by -`preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new -differential. - -The live path is unchanged. - -Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 -shipped under: the package's published surface is byte-unchanged — `src/index.ts` -is not in this diff and does not re-export `preview-evaluator.ts` at all, and -`aggregate()` is module-private — and the only user-visible effect is a drafted -chart's number moving to the number the published chart already showed. A value -correcting toward the live standard is a fix, not the backwards-compatible -feature addition `minor` denotes. It is a real value change for a consumer -reading the preview response (`0` becomes blank), which is why the card was filed -separately rather than ridden along with #16203 — but the `0` it replaces was -never a number the platform promised. diff --git a/.changeset/preview-count-over-field-non-null.md b/.changeset/preview-count-over-field-non-null.md deleted file mode 100644 index 5df758e365..0000000000 --- a/.changeset/preview-count-over-field-non-null.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face - -A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube -metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried -that field in and never read it — it answered the ROW count, nulls included, -while every SQL face lowers the same measure to `COUNT("payer")`, defined over -non-null values. A drafted chart therefore showed a different number than the -published one, silently, and the number it showed was the one `count(*)` gives: -the author's choice to count a specific column had no effect on the preview path. - -Measured on one dataset, one row set, two `AnalyticsService` instances differing -only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated -SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered -`payer_count` 1 live and 2 on preview. Both now answer 1. - -Unchanged, and pinned by the same differential: `count` with no field and `count` -with `field: '*'` still answer the row count (the compiler writes -`sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and -`count_distinct` still answers a cardinality. A group in which no row carries a -value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the -identity `0`. - -The live path is unchanged. - -Bumped `patch` rather than `minor`: the package's published surface is -byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is -module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and -the only user-visible effect is a drafted chart's number moving to the number -the published chart already showed, which is a correction toward the live -standard rather than the backwards-compatible feature addition `minor` denotes. diff --git a/.changeset/protection-block-unknown-key-refusal.md b/.changeset/protection-block-unknown-key-refusal.md deleted file mode 100644 index 0116075967..0000000000 --- a/.changeset/protection-block-unknown-key-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) - -`ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: - -``` -AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) - ✗ protection: Unrecognized key: "lockk" -``` - -`lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. - -It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: - -``` - ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. - Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. -``` - -Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. - -**Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. diff --git a/.changeset/protocol-version-gap-key-rename.md b/.changeset/protocol-version-gap-key-rename.md deleted file mode 100644 index aa5a7b884b..0000000000 --- a/.changeset/protocol-version-gap-key-rename.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/cli": minor ---- - - - -feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) - -**BREAKING** — a published machine surface changes a key name. `os validate --json` and -`os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A -consumer reading `specVersionGap` reads `undefined` after this release and must switch to -the new name. There is **no alias and no dual-key transition window**: one axis, one name. - -The value shape is unchanged — `null` when the app's declared compatibility range admits -the installed `@objectstack/spec`, otherwise the same advisory record with the same -members. Nothing else on either payload moves: no other key is added, removed or -reshaped, and the text faces of both commands are byte-identical. - -## Why the name had to move - -The axis this advisory reports moved in **#13860**: it used to read the undeclared -`manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared -(`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published -key name stayed behind for one release, deliberately — renaming a machine face with -pinned consumers is a break, and no ruling covered it at the time. - -Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites -the reader — an AI agent above all — to infer that a writable `manifest.specVersion` -exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), -so acting on that inference does not produce an error: it produces a manifest that looks -entirely normal and whose `specVersion` line never took effect. That is the same -ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on -the output side. - -## What a consumer should do - -```diff -- if (payload.specVersionGap) { … } -+ if (payload.protocolVersionGap) { … } -``` - -The breaking surface was measured before the rename and is closed inside this repository: -the only consumers of the old key were three in-repo e2e suites, which move in this same -change; **zero external consumers were found**. Graded `minor` by the maintainer's -explicit grading of 2026-09-02; the banner above carries the breaking-ness the level -cannot. diff --git a/.changeset/publish-honours-or-refuses-declared-manifest-id.md b/.changeset/publish-honours-or-refuses-declared-manifest-id.md deleted file mode 100644 index 1dc053f6da..0000000000 --- a/.changeset/publish-honours-or-refuses-declared-manifest-id.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -`os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. - -Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. - -``` -manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) -manifest.name = 'Acme CRM' - after: ✗ Invalid manifest-id 'crm'. … (exit 1) -``` - -`sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. - -- **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. -- **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. -- **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. - -What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. diff --git a/.changeset/quiet-pugs-tickle.md b/.changeset/quiet-pugs-tickle.md deleted file mode 100644 index c523bf123b..0000000000 --- a/.changeset/quiet-pugs-tickle.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os explain query` now teaches the two keys `QuerySchema` actually declares. - -The entry's example and its two optional-table rows named `filters` and `sort`. -Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both -were dropped silently: an author who copied the example got a query that parsed -clean and ran with no filter and no ordering, with nothing in the output saying -so. - -Both faces now read the schema's own spellings: - -- `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a - condition on that field (a bare value is implicit equality, an object is a map - of `$` operators), and `$and` / `$or` / `$not` combine conditions. -- `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled - `order`; `direction` is rejected by name. - -No schema changed, and no accept set moved: the correction is to the catalog -entry only. The `os explain` catalog sweep also gains a key-retention assertion -— an example must parse **and** come back with every key it declares — so the -next entry whose schema strips a key is named instead of passing. diff --git a/.changeset/raw-mount-declared-envelope.md b/.changeset/raw-mount-declared-envelope.md deleted file mode 100644 index a887ed4f7d..0000000000 --- a/.changeset/raw-mount-declared-envelope.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -**`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. - -The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. - -Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. diff --git a/.changeset/read-audit-preserve-view-instant.md b/.changeset/read-audit-preserve-view-instant.md deleted file mode 100644 index 47b7614fb9..0000000000 --- a/.changeset/read-audit-preserve-view-instant.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) - -`sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. - -`persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. - -The write now declares both context keys, for two different layers: - -```ts -await engine.insert( - 'sys_audit_log', - rows as any, - { context: { isSystem: true, preserveAudit: true } } as any, -); -``` - -`isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. - -**What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. - -**No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. diff --git a/.changeset/readonly-create-side-bucket-exclusion-narrows.md b/.changeset/readonly-create-side-bucket-exclusion-narrows.md deleted file mode 100644 index a126ca48f0..0000000000 --- a/.changeset/readonly-create-side-bucket-exclusion-narrows.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -"@objectstack/objectql": minor -"@objectstack/lint": minor ---- - -fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) - - - -**BREAKING** for a non-system caller that CREATES a static `readonly` column on an -object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name -outside the reserved `sys_` namespace: the forged value used to be persisted and is -now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop -reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` -under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). -That is exactly what the same caller's UPDATE of the same column already did. Shipped -as `minor` under the repo's launch-window convention. - -## The census, both halves — neither one is the whole reading - -**(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit -in the three now-judged buckets and carry a static `readonly` column between them — 64 -columns in all: - -- `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, - `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` -- `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, - `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` -- `system-data` (8 objects, 21 columns): `sys_approval_delegation`, - `sys_notification_preference`, `sys_notification_subscription`, - `sys_notification_template`, `sys_position_permission_set`, - `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` - -**And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree -carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above -included — so the namespace test, which this change does not touch, keeps all of them -exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct -non-system `engine.insert` from the metadata repository, is doubly exempt -(`engine-owned` bucket **and** `sys_`) and is pinned as such. - -⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the -population the narrowing reaches is real and named above, and an app that declares one -of those buckets on its own object gets the strip. The population on its own -understates it — not one shipped object changes behaviour on this release. What moves -is the contract for **app-authored** objects, which is the population the ruling is -about. - -## What was wrong - -`staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, -carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / -#3004 grounds: those columns have their own 403 guards, and a silent strip must not -swallow the payload the guard exists to reject. The argument is sound and the bucket -list was not. `managedBy: 'system-data'` means "platform-defined schema, -**admin/user-writable data**" by its own definition, and `object.zod.ts` says in the -same breath that it "carries no such guard; its writes are adjudicated by the -delegated-admin gate / RLS / permission sets". So the create side skipped the strip on -objects whose data is the user's, while the update side stripped them — and #14147's -"one semantics, one enforcement point" was not literally true on that population. - -## What it does now - -The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for -the three buckets whose columns really do carry a fail-closed refusal: - -| bucket | its own refusal | the create-side strip | -|:--|:--|:--| -| `engine-owned` | ADR-0103 engine-owned write guard | steps around it | -| `append-only` | ADR-0103, same guard (locked default) | steps around it | -| `better-auth` | ADR-0092 identity write guard | steps around it | -| `platform` | none — full user CRUD by default | judges it | -| `config` | none — admin-authored, writable by default | judges it | -| `system-data` | none — "admin/user-writable DATA" | judges it | - -An **unrecognised** bucket value is deliberately not read as platform-internal: the one -legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and -converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt -precisely the rows that conversion targets. The partition is pinned against -`@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing -silently on one side. - -The ruling's fallback ("leave it, if those buckets' readonly columns already carry -their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 -package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, -`drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / -`created_at` / `updated_at` stamps, which that guard does not reach. - -`@objectstack/lint` mirrors this predicate to decide which objects its create-verb -`flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, -and is narrowed in the same stroke — a lint that kept the wider exemption would go on -suppressing findings for a strip that now really happens. - -⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. -The asymmetry is closed by moving CREATE toward UPDATE. diff --git a/.changeset/readonly-insert-superseded-prose.md b/.changeset/readonly-insert-superseded-prose.md deleted file mode 100644 index b81a862b98..0000000000 --- a/.changeset/readonly-insert-superseded-prose.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch -"@objectstack/rest": patch ---- - -Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. - -The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. - -No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. diff --git a/.changeset/repeater-item-schema-titles-class-guard.md b/.changeset/repeater-item-schema-titles-class-guard.md deleted file mode 100644 index 9e8f0e6322..0000000000 --- a/.changeset/repeater-item-schema-titles-class-guard.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) - -## What was wrong - -Studio renders a `type: 'repeater'` form field as a table whose column headers -read `items.properties[k].title ?? k` off the JSON Schema served by -`GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, -i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. -The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only -replaces a title that is already there, so an item schema carrying no -`.meta({ title })` falls through to the raw machine key — in **every** locale, -English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` -inside an otherwise fully translated panel. This is a missing authoring label in -the contract, not a translation gap. - -PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped -by dispatch to that one. **The class stayed silent**: the next repeater to land -would reproduce the defect with every gate green. - -## Measured on `origin/main` at `e758131b39` - -22 repeater fields are declared across 11 `*.form.ts` files. Derived through the -platform's own predicate rather than a source regex: - -- **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. -- **1** has no object row shape at all — `action.locations` is an array of enum - STRINGS, so it renders no column headers and leaks no key. It is **not** a - carrier, which is why the class is **20** untitled tables today and not the 21 - the card premised. -- **20** were untitled. - -## What changed - -**Thirteen carriers are now titled** — every row property of `action.params`, -`app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, -`flow.edges`, `flow.variables`, `page.variables`, `page.regions`, -`page.interfaceConfig.sort`, `report.order`, `report.blocks` and -`skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` -is titled through the shared `SortItemSchema` it composes. - -**The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` -enumerates every repeater declared across every `*.form.ts` in the package, -derives each row schema through `z.toJSONSchema`, and requires a title on every -authorable row property. Carriers still owed one sit in an EXACT, shrink-only -ledger: a repeater absent from the ledger must be fully titled, and a ledger -entry whose debt has been paid must be deleted. A new repeater is therefore red -on the day it lands, and the ledger can only shrink. - -Two exclusions the pin makes deliberately, each with its own control: - -- a `retiredKey()` tombstone is a parse-time refusal, not an authorable column - (`flow.nodes[].outputSchema`); -- a scalar-item repeater has no row properties to name (`action.locations`), - and is pinned by name so an object-shaped one cannot land there silently. - -## What is still owed, and why - -Seven carriers remain on the ledger because their item schemas live in files held -by other in-flight PRs at the time of writing — `dashboard.widgets` and -`dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / -`view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` -(the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them -without editing them, so the ledger states the whole class rather than the slice -one PR could reach. - -Localisation is additive and unchanged by this round. `.meta({ title })` is the -English authoring layer by contract — `translation.zod.ts` states it in those -words — and a bundle's `metadataForms..fields...label` -overlays it per locale. No form file here enumerates repeater children, so -`os i18n extract` emits no new catalog keys and no catalog moves. Until those -leaves are authored, a non-English panel shows the English title rather than the -machine key — strictly better than today, and the localisation layer is still owed. diff --git a/.changeset/reserved-identity-name-position-guard.md b/.changeset/reserved-identity-name-position-guard.md deleted file mode 100644 index 1e2ca18ee3..0000000000 --- a/.changeset/reserved-identity-name-position-guard.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) - -`sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. - -Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). - -Two doors, two shapes, for a reason: - -- **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. -- **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. - -Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. - -Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. diff --git a/.changeset/retire-adr-0030-notification-event-migration.md b/.changeset/retire-adr-0030-notification-event-migration.md deleted file mode 100644 index 2ae652749b..0000000000 --- a/.changeset/retire-adr-0030-notification-event-migration.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/metadata': minor -'@objectstack/spec': minor ---- - -**BREAKING** — retire the `adr-0030-notification-event` data migration. - -`migrateSysNotificationToEvent` had no way to be run: zero production callers -anywhere in the repo, and no `os migrate` sub-command, while the two sibling -members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel -export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's -pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed -together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform -on this line. - -## What is gone, and what an upgrader does about it - -⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to -adopt — every item below is a deletion, and the fix is to stop using it. - -- `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. - No replacement exists, and none is coming: an `os migrate notification-event` - sub-command was considered and refused. Delete the call. The compiler delivers - this one: the import fails to resolve. -- `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and - `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They - described that runner's own result, options and receipt and nothing else. -- `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a - three-member tuple and is now a two-member one holding - `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids - keep their sub-commands, their receipt rows and their birth attestation; only - the notification id left. Code typed against - `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification - id no longer compiles — delete that arm. - -`NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A -deployment attested at birth, or one that made the operator call while the runner -shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, -and the constant is that row's name. Nothing writes or reads a row under it any -more — `attestFreshDatastore` no longer includes it — and it is not a -registration: it gates nothing and never did. - -## Reversal path - -Two answers were considered and both refused: an `os migrate notification-event` -sub-command is a permanent operator surface for a migration with no measured -demand, and a boot-time invoker is an unattended data rewrite nobody asked for. -⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 -`sys_notification` rows. If a **named** deployment turns out to hold rows it -needs, the migration returns as an operator-runnable sub-command shaped exactly -like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, -documented consequence — under its own card. - - diff --git a/.changeset/retire-list-view-page-mount.md b/.changeset/retire-list-view-page-mount.md deleted file mode 100644 index 07f1fb2c30..0000000000 --- a/.changeset/retire-list-view-page-mount.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/metadata-protocol': minor ---- - -**BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. - -A list view could declare `type: 'page'` and name a published page in `pageName`, -and the view was to render nothing of its own and delegate to the page renderer. -Only the spec half of that was ever built. **No renderer ever routed the member**: -objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page -view has always drawn an empty table where the page was supposed to be, and the -three parse refusals that policed the binding policed a mount that never mounted -anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. - -## FROM → TO - -| you wrote (17.4 and earlier) | write instead | -| --- | --- | -| `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | -| `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | -| a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | - -**The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put -the page behind an app navigation item, which is a different key on a different -surface (`PageNavItem.pageName`) and is the page mount that has always rendered. - -`os migrate meta --from 17` lists the mechanical edits for existing sources; apply -them by hand. - -## The retirement kit - -- **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and - `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse - raises the prescription rather than a bare unrecognized-key report. -- **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on - (the def survives, one value lighter, and the four generated-surface ratchets are - blind to that by construction). The `type` enum's own `error` map carries it, - keyed on `issue.input` so only the value that used to be legal gets the - "was removed" message; every other invalid `type` keeps zod's default text. -- **`checkListViewPageMount`** — the exported object-level refinement existed only - to police this mount, so it is removed with it, along with its three refusal - messages. A downstream mirror that re-attached it (the reason it was exported) - should drop the `.superRefine` line; the compiler delivers this one. It held no - `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. -- **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the - `os validate` and publish-gate rule that resolved a mount against `stack.pages`. - Removed: there is no reference left to resolve. Its nav twin - (`validateNavTargetRefs`, on the app navigation item) is **untouched**. -- **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of - `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page - universe joined the per-write snapshot for that one rule, and leaves with it. A - `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a - collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / - `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. -- **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's - `pageName` against `stack.pages` is gone. The surviving three page references in - that function (an app nav item's `pageName`, a modal action's `target` at two - rungs) keep their own policy. -- **The metadata form** — `view.form.ts`'s `page` section, whose one input was - `pageName`, is removed. A form input for an unwritable key is the false-compliant - UI half of a retirement. - -## What an operator with a STORED page view sees - -A `sys_metadata` `view` row written before this release can carry `type: 'page'` and -a `pageName`. Nothing breaks at read: the ADR-0087 conversion -`view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, -so the row is served canonical. `type` is **stripped, not rewritten** — it defaults -to `grid` in the schema, so the row lands on exactly what it already rendered -without the platform guessing a view type. - -The strip is announced once per row per process, on whichever seam served it. -Grep for `carries a pre-protocol shape` — there are **three** emitters, one per -rehydration seam, and they differ: - -- `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` -- `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` -- `[Protocol] stored view/ carries a pre-protocol shape; The row - itself is unchanged — re-save it (Studio edit -> save, or run - "os migrate meta --stored --apply") to persist the canonical shape.` - -`os migrate meta --from 17` lists the same edits for authored sources; -`os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and -the next save through `PUT /api/v1/meta/view` heals one row the way it heals any -pre-protocol shape. - -⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does -**not** reach `objects[].listViews.*`, which no conversion in the registry reaches. -An object body still carrying a page mount is refused at its own door with the -prescription rather than converted. Measured population for both at the ruling: -**zero** authored `type: 'page'` list views in this repository or any consuming app -the seats can read — the in-tree `type: 'page'` hits are all app nav items. - - diff --git a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md b/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md deleted file mode 100644 index 2ddc533207..0000000000 --- a/.changeset/rls-accessible-org-ids-resolved-into-variable-bag.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) - -`patch` — a bug fix in a released package. No API signature changes, no exported -symbol added, no spec or ADR edit: the contract already promised this, and only -the line that delivers it was missing. - -## What was wrong - -`packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve -the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, -`accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved -and not an app resolver, and lists it at `:70` in -`RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when -it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes -further and names the RLS spelling outright: *"RLS policies may reference it as -`organization_id IN (current_user.accessible_org_ids)`"*. - -`RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` -and `email`, and nothing copied `accessible_org_ids` out of the execution -context. So the key was reserved on the grounds that core resolves it, and core -did not resolve it — a slot with a declared shape and no filler, which is the -ADR-0049 "declared but unenforced" shape. - -**The cost is the invisible one.** A predicate such as -`employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved -variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned -**zero rows with no error raised**. Nothing failed. An empty list is -indistinguishable from "this user really has no data", which is how the shape -survived three green static gates and, in the reporting app, left ten policies -across six objects inert — the entire multi-tenant isolation model. - -The failure direction is **closed**: zero rows, never a cross-tenant read. This -is a usability and declared-means-enforced defect on a security surface, not a -leak. - -## What it does now - -`RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into -`RLSUserContext`, following `org_user_ids`' precedent exactly — both are -core-resolved membership sets the runtime **pre-resolves**, precisely so this -compiler never has to issue a subquery. The compiler is unchanged otherwise; it -already handled the value correctly once present. - -The producer already existed and is unconditional: `resolve-authz-context.ts` -types the set as required and `assemble-execution-context.ts` copies it on every -face, in every posture (*"in `single` posture the set is resolved but no wall -consumes it"*). Only the consuming line was missing. - -One consequence worth naming: **reserved now means reserved at the compiler -too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, -but a bag already present on the context was spread through unscreened, and -landed in the variable bag because nothing named the field. Now that the kernel -names it, the compiler's own "a membership key never clobbers a named field" -rule covers it and the kernel's value wins. - -## Measured, end to end - -A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across -three organizations, a caller holding membership in two of them: - -| predicate | before | after | -|:--|--:|--:| -| `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | -| same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | -| same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | -| a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | -| `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | - -An app **could** work around the defect by supplying the same set under its own -unreserved key through `rlsMembership` and rewriting its predicates to -`current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. -The workaround costs every app a membership-resolver registration it should not -need and moves every predicate off the documented spelling — and it is no longer -necessary. diff --git a/.changeset/rls-predicate-references.md b/.changeset/rls-predicate-references.md deleted file mode 100644 index feb36afed1..0000000000 --- a/.changeset/rls-predicate-references.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. - -The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. - -Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. - -An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. - -An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. - -⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. - -- **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. -- **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. -- **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. -- **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. - -**What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. - -A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. diff --git a/.changeset/rls-reserved-membership-keys-refused-by-name.md b/.changeset/rls-reserved-membership-keys-refused-by-name.md deleted file mode 100644 index 07cd55324b..0000000000 --- a/.changeset/rls-reserved-membership-keys-refused-by-name.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name - -A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED -kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, -`accessible_org_ids`, `email` — whenever the kernel had not resolved a value for -that key on the request. `RLSCompiler.compileFilter` admitted a membership key on -the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), -not on whether the key is reserved, so an absent kernel value handed the name to -the bag. - -The direction was widening. With the key unresolved, the predicate referencing it -fails CLOSED — it joins the dropped-policy path and the compile returns the deny -sentinel, which yields zero rows. The bag instead produced a satisfiable filter -over caller-chosen values, converting a denial into a match. - -The merge now refuses reserved keys by name, at the one seam both faces pass -through (the read layer compiles `using` there, the ADR-0058 D4 write gate -compiles `check` there). `stageRlsMembership`'s existing screen covers only the -registered resolver's answer, and only when a resolver is registered at all — it -returns at its first line otherwise — so it could not carry this guarantee. - -No behaviour change for non-reserved membership keys, and none when the kernel -did resolve the reserved value: the kernel's value already won, and still does. -A refused key simply stays unresolved, so its policies drop out and fail closed -through the reason vocabulary that already exists. diff --git a/.changeset/rls-undeclared-column-denies-in-every-position.md b/.changeset/rls-undeclared-column-denies-in-every-position.md deleted file mode 100644 index 3f6752cbab..0000000000 --- a/.changeset/rls-undeclared-column-denies-in-every-position.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/lint": patch ---- - -fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) - - - -**BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. - -A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. - -⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". - -Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): - -- **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. -- **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. - -**The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. - -⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. - -**Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. - -**driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. - -`@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. diff --git a/.changeset/rollup-non-numeric-aggregand.md b/.changeset/rollup-non-numeric-aggregand.md deleted file mode 100644 index 1673c5baed..0000000000 --- a/.changeset/rollup-non-numeric-aggregand.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -`os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. - -`FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). - -- **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. -- **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. -- **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. -- **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. - -No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. diff --git a/.changeset/runtime-gate-overlay-redefinition-universe.md b/.changeset/runtime-gate-overlay-redefinition-universe.md deleted file mode 100644 index 317d41c578..0000000000 --- a/.changeset/runtime-gate-overlay-redefinition-universe.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) - -The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. - -Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: - -- a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; -- a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. - -One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. - -The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. - -**#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. - -Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. diff --git a/.changeset/s3-adapter-key-namespace.md b/.changeset/s3-adapter-key-namespace.md deleted file mode 100644 index 66f679c9a3..0000000000 --- a/.changeset/s3-adapter-key-namespace.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/service-storage": minor ---- - -**Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. - -**BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. - -The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. - -`keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. - -**Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. - -For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. - -Two further seams move with it: - -- `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. -- `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. - -`LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. - -**Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. - - diff --git a/.changeset/sandbox-crash-outranks-declared-code-arm.md b/.changeset/sandbox-crash-outranks-declared-code-arm.md deleted file mode 100644 index 4ecad6d3f4..0000000000 --- a/.changeset/sandbox-crash-outranks-declared-code-arm.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) - - - -**BREAKING** — the answer this published door gives moves for existing inputs. -No export, signature or declared type changes; what changes is the response an -existing call observes, and a client branching on `error.code` for the affected -shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` -under the launch-window convention (`major` is refused while the fixed group -versions in lockstep), so this banner — not the level — is the breaking-ness -signal. - -**What changes for an operator.** A sandboxed hook or action body that declared a -refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later -line — used to answer the single-record `/api/v1/data` routes with the code's own -business status and the QuickJS debug sentence as the client-facing message, for -example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a -function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message -and no crash text, which is what the same crash carrying no declared code has -always answered. The full wrapper still reaches the server log through the -existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator -diagnoses with is lost. - -**What does NOT change.** An ordinary declared refusal — a hook that throws a -business error carrying a code and does not crash — is untouched: same status, -same code, same sentence, same structured fields. So is every non-sandbox -producer of those codes, and so is the `developerMessage` channel, which keeps -the rule it already had for a fault. - -**Why.** A declared code is the author's statement about the failure mode they -handled; a crash is not that mode. Answering one with a business status shipped -an internal, stack-shaped sentence to an end user and told the client the wrong -thing about what happened, while the door one branch down already sanitised the -identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. - -**If you were relying on the old answer,** the affected shape is a hook that -declares one of the classification's ten code-gated refusals and then faults: it -now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is -the point of the change — the crash was never the refusal the code named. diff --git a/.changeset/schedule-trigger-acting-organization.md b/.changeset/schedule-trigger-acting-organization.md deleted file mode 100644 index e64819ac1b..0000000000 --- a/.changeset/schedule-trigger-acting-organization.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/trigger-schedule": minor -"@objectstack/lint": minor ---- - -fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) - - - -**Registered as an ADR-0087 semantic migration** -(`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable -is renamed, retired or re-typed — no `packages/spec` key changes its name, its -type or its optionality, no stored shape moves, and every flow, node and -start-node `config` that parses today parses byte-identically afterwards, -because the start node's `config` is an OPEN record (ADR-0018) and the new -`organization` key is an addition to a slot that already accepted anything. So -`objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a -value only the deployment holds, a `sys_organization.id` minted at runtime, with -no authored artifact and no stored representation a rewrite could act on — and -inventing one is precisely what the ruling forbids. ⚠️ That is the argument -against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a -migration that cannot be expressed declaratively gets a structured TODO -(surface, reason, acceptance criteria) rather than nothing, and what follows IS -a prescription in that sense — declare `config.organization` once per -organization, no fan-out, then act on the three consequences of the split named -below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — -behaviour-only, no shape moved, a deployment judgement no transform can make, -registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before -this narrowing landed, so the enforcement rides the 17.x line by the -launch-window convention while the prescription belongs at the major boundary -where `migrate meta` users look. - -**BREAKING** in the accept-set sense, and in TWO places rather than one — -landing in the launch window as `minor` on all four packages (the lockstep -convention: during the window the bump level is not the carrier, this banner and -the disposition above are). Nothing that was refused becomes admitted. - -1. **Bind time.** A `schedule` or `time_relative` flow that declares no - `organization` is no longer armed. -2. **Run time — the DATA PLANE.** A time-triggered run now carries a - `tenantId`, and a `time_relative` sweep now carries one on its own query. - Where a run previously read, updated and deleted across every organization, - it is now confined to the one it declares. - -⚠️ **Read (2) as a narrowing that can stop something that was working**, because -it is one. Two shapes to plan for, and neither is hypothetical: - -- **A deployment running ONE time-triggered flow to cover ALL organizations must - now declare one flow per organization.** That is the ruling - (「不允许跨组织的定时任务」) and it is the whole point, but it is migration - work: there is no fan-out, and a sweep wanted in N organizations is N - declarations. Nothing detects the shape for you — the flow simply starts - seeing one organization's rows. - - ⚠️ **And the split has three effects the sentence above does not carry.** Each - is deployment work, and none of them is detected for you either: - - 1. **A NULL-organization row fans out N-fold.** The driver's scope is - `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no - tenant column value stays visible to a *scoped* read — this PR's own - negative control fixture selects exactly that row under scope, on purpose. - After the split every `organization_id IS NULL` row in a swept object is - therefore matched **once per flow**: N runs, N notifications, each acting - as a different organization. Before the split it was matched once. ⇒ Either - backfill the tenant column on swept objects or declare the object - platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the - scope rather than multiplying under it. - 2. **The current window's dispatch claims are abandoned.** The dedup key - embeds the FLOW NAME — `schedule::` and - `time-relative:::` — so N differently-named - flows claim under N different keys. A window already delivered under the - old name can deliver again, once, under each new one. ⇒ Cut over at a - window boundary, or accept one duplicate window. - 3. **A run suspended before the upgrade is not retroactively confined.** - Resume rebuilds the run's context from `context_json` - (`suspended-run-store.ts`), and a row written before this change carries no - `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills - it. Not a regression (that is how it already ran), but the banner would - otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight - suspended time-triggered runs, or accept that the tail of them is - unconfined. -- **On a SINGLE-organization install a time-triggered flow WAS delivering** — - the #8844 guard derives the only organization there — and after this change it - is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` - that install loses nothing at run time once the line is added: the scope is - `org = :tenant OR org IS NULL` and its one organization is the only scope there - was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss - has no legal configuration.** That driver refuses *any* call handed a tenant - scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) - — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / - `bulk*` / `aggregate`, one call at a time, regardless of how many - organizations the install holds. So a time-triggered flow that touches - per-organization data on that driver is refused per call if it declares an - organization and unarmed at boot if it does not. The declaration is not what - breaks it — the driver has no row-level tenant isolation to offer either way — - but this change is what moves such a flow from the "no organization context at - all → served" case into the refused one. Multi-organization deployments use - `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are - genuinely platform-global can declare them so (`tenancy: { enabled: false }`, - ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the - refusal on data that really is per-organization. - -A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. - -Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 - -A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. - -- **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. -- **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. -- **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. -- **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. - -**What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. - -⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. - -**Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. - -No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. - -**What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: - -- **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. -- **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. - -**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. diff --git a/.changeset/scoped-packages-dispatcher-door.md b/.changeset/scoped-packages-dispatcher-door.md deleted file mode 100644 index 2036027c13..0000000000 --- a/.changeset/scoped-packages-dispatcher-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) - -**The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. - -`mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. - -**The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: - -- `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. -- `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. - -A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. - -The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. diff --git a/.changeset/scoped-sdk-honours-metadata-prefix.md b/.changeset/scoped-sdk-honours-metadata-prefix.md deleted file mode 100644 index 590236786a..0000000000 --- a/.changeset/scoped-sdk-honours-metadata-prefix.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/client': patch ---- - -fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` - -`metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata -route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler -advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. -Three surfaces describe one set of paths — the mounts, the discovery document, -and this SDK. - -`ScopedEnvironmentClient` restated `/meta` as a literal in all six of its -metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, -`getHistory` — so on a deployment that moved the prefix, every one of them -called a path the server does not mount. The unscoped twin of each method was -already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK -disagreed with itself: the unscoped half read the advertised value while the -scoped half guessed. Measured on a live server booted at -`metadata: { prefix: '/metadata' }`, all six went to -`/api/v1/environments//meta`, which that deployment answers 404. - -The six now build through `metaUrl()`, which takes its base from `_apiBase()` -and its prefix from the new `_metaPrefix()` — the exact sibling of the -`_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline -included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the -prefix from `routes.data` as a second equation over the same `realBase` when the -advertised value is not the conventional one, and **declines to `/meta`** -whenever the document does not determine the answer: an SDK must not become -unusable because a server's discovery document is missing a key. - -Deployments on the default prefix are unaffected, by construction and by -measurement: the conventional-suffix rule is taken first, so a default -deployment is answered from `routes.metadata` alone, and a client that never -connected never reaches a rule at all. The pinned negative control asserts the -six request URLs of a default deployment byte for byte, for a connected client -and for an unconnected one, and that the unconnected client puts no discovery -request on the wire. - -The unscoped metadata methods are untouched. diff --git a/.changeset/sdui-parser-stageorder-funnel-only.md b/.changeset/sdui-parser-stageorder-funnel-only.md deleted file mode 100644 index 57810963f2..0000000000 --- a/.changeset/sdui-parser-stageorder-funnel-only.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/sdui-parser': patch ---- - -`dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` - -The accepted-set census comment at the top of the module (carried into the -published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". -There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author -who copied the pair got a parse refusal. The line now says what the schema's -own `.describe()` says: `funnel` is the only widget type that reads the key. -Comment-only — the accepted set, the diagnostic code and the emitted JS are -unchanged. diff --git a/.changeset/security-fls-unknown-field.md b/.changeset/security-fls-unknown-field.md deleted file mode 100644 index 0c3782ac14..0000000000 --- a/.changeset/security-fls-unknown-field.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. - -`security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. - -The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. - -- **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. -- **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. -- **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. -- **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. - -**What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. diff --git a/.changeset/seed-locale-producer-wiring.md b/.changeset/seed-locale-producer-wiring.md deleted file mode 100644 index a5e6555ec4..0000000000 --- a/.changeset/seed-locale-producer-wiring.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/spec": patch ---- - -`AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. - -The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. - -- **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. -- **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. -- **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. - -`SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. - -The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. - -⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. - -⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. diff --git a/.changeset/serve-org-remedy-defers.md b/.changeset/serve-org-remedy-defers.md deleted file mode 100644 index c9ddb300ab..0000000000 --- a/.changeset/serve-org-remedy-defers.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. - -Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. - -The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. diff --git a/.changeset/single-posture-organization-census.md b/.changeset/single-posture-organization-census.md deleted file mode 100644 index 2e949a573c..0000000000 --- a/.changeset/single-posture-organization-census.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-auth": minor ---- - -fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) - -ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. - -The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. - -**The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. - -Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. diff --git a/.changeset/solution-blueprint-module-header.md b/.changeset/solution-blueprint-module-header.md deleted file mode 100644 index a91da82efd..0000000000 --- a/.changeset/solution-blueprint-module-header.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. - -The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: - -``` -- `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … -``` - -A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. - -`SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: - -``` -- `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) -``` - -The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. - -Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. diff --git a/.changeset/spec-approval-continue-restored-contract.md b/.changeset/spec-approval-continue-restored-contract.md deleted file mode 100644 index fc5e4198d1..0000000000 --- a/.changeset/spec-approval-continue-restored-contract.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. - -`IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. - -Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. diff --git a/.changeset/spec-cloud-provided-package-version.md b/.changeset/spec-cloud-provided-package-version.md deleted file mode 100644 index 1ee95fcc3f..0000000000 --- a/.changeset/spec-cloud-provided-package-version.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: -`sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now -returns `true`, so a reference to that name resolves instead of being flagged as -a platform-prefixed name nothing registers (#16745). - -This widens an accept set. The name was previously refused, the list is a closed -set, and nothing in the published header enumerated this member — so the ladder -now accepts a value it used to warn on, and the widening reaches every surface -that consults the predicate: a dataset `object`, an action parameter -`reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` -naming `sys_package_version` all stop being diagnosed. - -Why this name and not another: the list already carried `sys_package` and -`sys_package_installation` — the head and tail of the three-table package family -that `cloud/package.zod.ts` declares — but not the release-snapshot table -between them, whose row schema this repository ships as -`cloud/package-version.zod.ts`. Platform metadata that ships with the product -references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` -is a `Field.lookup('sys_package_version', …)`. - -One entry is added; no other member moves and nothing is removed or narrowed. -The cloud-side half of the contract — that `@objectstack/service-tenant` -registers the table — is owned by the cloud repository per the list's header and -is not asserted from here. diff --git a/.changeset/spec-cloud-subpath-retired.md b/.changeset/spec-cloud-subpath-retired.md deleted file mode 100644 index ecbef526de..0000000000 --- a/.changeset/spec-cloud-subpath-retired.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": patch -"@objectstack/metadata": patch ---- - -feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) - - - -**BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no -alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, -用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window -convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness -is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription -is registered under protocol major 18 as `cloud-subpath-retired`. - -## What moved, and why - -Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, -ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). -`packages/spec/src/cloud/` held two families with different owners: - -- **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, - `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema - defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the - open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: - `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and - the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it - is recoverable from git history at `d5d8d50db`. -- **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, - `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the - open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` - and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is - byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the - author-facing contract). - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | -| `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | -| `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | -| `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | -| `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | - -Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` -deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking -binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That -type no longer exists in the open-source package, so the wrong binding is structurally -impossible rather than warned about in a docblock. - -`@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and -`system` respectively); no behaviour moves. diff --git a/.changeset/spec-functional-completeness-symbol-anchors.md b/.changeset/spec-functional-completeness-symbol-anchors.md deleted file mode 100644 index 5cc858614d..0000000000 --- a/.changeset/spec-functional-completeness-symbol-anchors.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) - -The module doc block of `kernel/functional-completeness.ts` cited the runtime that -justifies each rule by line number. All three had rotted: re-measured on `origin/main` -`7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 -against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text -occurs exactly once in `engine.ts`, so those are readings rather than artefacts. - -The citations are the only limb tying a rule's justification to the runtime that -implements it, and that limb is walked by a human reading it — nothing in the module can -notice the runtime moved. `:3191` was the dangerous one: the line it names today is -ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and -never learns they were sent to the wrong place. - -Each now names the enclosing symbol in the repo-root `path#symbol` form -`packages/spec/liveness/field.json` already uses — -`packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, -`#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot -again on the next refactor; a symbol plus a unique snippet is greppable and survives -movement. The anchor form also moves these three from -`check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now -reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. - -Doc text only — no schema, export, type or runtime behaviour changes. It ships because -this block is emitted into the published `dist/kernel/index.d.ts`. diff --git a/.changeset/spicy-pears-count.md b/.changeset/spicy-pears-count.md deleted file mode 100644 index 3476abdd66..0000000000 --- a/.changeset/spicy-pears-count.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. - -The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. diff --git a/.changeset/spotty-jars-shave.md b/.changeset/spotty-jars-shave.md deleted file mode 100644 index eea558bf3d..0000000000 --- a/.changeset/spotty-jars-shave.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. - -A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. - -Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. - -⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. diff --git a/.changeset/standalone-plugin-scaffold-unscoped-private.md b/.changeset/standalone-plugin-scaffold-unscoped-private.md deleted file mode 100644 index 5792287e88..0000000000 --- a/.changeset/standalone-plugin-scaffold-unscoped-private.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os create plugin` names the standalone scaffold `plugin-` and marks it `private` - -The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a -project scaffolded for a developer outside this monorepo — a scope they cannot publish -to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is -never resolved from a registry inside the project, so `pnpm install`, the type-check and -the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The -emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. - -The standalone default now emits: - -- `"name": "plugin-"` — unscoped, and the same string as the directory the - scaffolder prints and creates; -- `"private": true` — the line that actually stops an accidental publish, whatever the - name says; -- a README whose install instruction is a local reference (`pnpm add link:../plugin-`) - and whose import specifier matches the emitted package name. - -`os create plugin --in-repo` is unchanged: it still emits a publishable -`@objectstack/plugin-` with no `private` flag, because that placement lands under -`packages/plugins/` where every sibling genuinely carries that scope. - -No action is needed for a project already scaffolded. If you generated one with the old -name and have not published it, rename `package.json`'s `name` to `plugin-` (or a -scope you own) and update the README's install line; the exported symbol and the plugin's -runtime `name` are unaffected. diff --git a/.changeset/standalone-stamp-comment-accuracy.md b/.changeset/standalone-stamp-comment-accuracy.md deleted file mode 100644 index 9e8254b956..0000000000 --- a/.changeset/standalone-stamp-comment-accuracy.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/objectql": patch -"@objectstack/cli": patch ---- - -docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces - -The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. - -No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. - -The sites were judged individually rather than search-and-replaced, because they are not all the same edit: - -- Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. -- `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. - -The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. diff --git a/.changeset/strict-env-scope-roots-dyn.md b/.changeset/strict-env-scope-roots-dyn.md deleted file mode 100644 index a0ca4a33de..0000000000 --- a/.changeset/strict-env-scope-roots-dyn.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/lint": minor ---- - -fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) - - - -**BREAKING** in the accept-set sense — an accept-set narrowing on published -CHECKERS, in the same sense as a route that starts refusing a request it should -always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is -not the carrier of breaking-ness; this paragraph and the disposition above -are). Nothing that was already reported stops being reported, and no source -that is correct starts being reported. - -`firstUndeclaredReference` asks cel-js's checker for the first undeclared -identifier in a source. That checker returns exactly ONE error, and the helper -acts only on `Unknown variable: X`, so whenever the first error is of another -class every undeclared reference behind it in the same source went unjudged and -the helper answered `null` — which is also the value that means "every -reference is rooted". Four published call sites read that answer, and none of -them can tell the two readings apart. - -The widest way to reach that state was a disagreement between two environments -in this package about the same names. The strict env declared every -`SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, -`input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` -type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an -ordinary comparison on one of those names compiled clean and then faulted `no -such overload` in the strict env only — taking the single error slot and -silencing everything behind it. An author reaches it by naming an object field -or a flow variable after a namespace root and reading it bare, which on a -metadata-editing form is not even a coincidence: that layer binds the row under -edit as `data`. - -The strict env now declares those roots `dyn`, which is what the list's own -doc-comment already claimed it was for — member access, arithmetic and -comparison on a root all deferring to runtime — and which `map` delivered only -the first of. The two environments agree about these names, so the class cannot -arise rather than being compensated for downstream. - -What starts reporting, measured on each published surface: - -- `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare - reference behind a root name is the hard error it always was for the same - identifier written first (`ok` was `true` with zero errors; it is now `false`). -- `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the - did-you-mean warning reaches a misspelled field behind a root name. -- `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a - root name in a `visibleWhen` predicate is a finding. Per that rule's own - message the console otherwise falls open and the element renders - unconditionally. -- `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a - root name is warned. That rule's documented blind spot is now name-local, as - its wording always claimed: the colliding name itself is still not reported. - -⚠️ One published answer also WIDENS, and it is not a reporting surface. -`inferExpressionType` (`@objectstack/formula`, re-exported from the package -root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a -formula's coarse value type through `inferCelType`, which shares this same -strict environment. While the roots were `map` there was no `==`, `<` or `+` -overload for them, so an expression using a namespace root as a DIRECT OPERAND -did not type-check at all and the answer was `'unknown'`. With the roots `dyn` -those expressions type-check and the answer is the truthful CEL type: -`result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, -`data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No -answer changes from one concrete type to another and nothing narrows to -`'unknown'` — `size(record)` and `"a" in record` still answer, and a root that -is only the base of a member access (`record.amount > 100`) never consulted this -declaration. A consumer that keys off a concrete type therefore sees strictly -more expressions classified, never a different classification; for the -motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is -now correctly seen as text rather than as unprovable. Pinned on both sides in -`validate.test.ts`. - -⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL -TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no -declaration this package makes can reach it; measured on the strict env, the -message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root -declaration. And `has()` handed a non-select argument still faults its own -class, which `@objectstack/lint`'s visibility rule masks at its own call site -(#16118) and which nothing else masks. - -The narrowing this helper is built on is unchanged: it still acts only on -`Unknown variable`, so `type(record.x) == string`, comprehension macros, guard -idioms, optional chaining and stdlib calls report nothing, and a widening of -that regex onto the overload message remains refused. diff --git a/.changeset/sys-user-role-prose-retired-action.md b/.changeset/sys-user-role-prose-retired-action.md deleted file mode 100644 index 3d1f2800d6..0000000000 --- a/.changeset/sys-user-role-prose-retired-action.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -`sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) - -Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. - -- **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. -- **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. -- **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. - -**Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. diff --git a/.changeset/temporal-text-operator-declared-type-gate.md b/.changeset/temporal-text-operator-declared-type-gate.md deleted file mode 100644 index 7c525afb31..0000000000 --- a/.changeset/temporal-text-operator-declared-type-gate.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/driver-sql": minor -"@objectstack/driver-turso": minor -"@objectstack/driver-sqlite-wasm": minor -"@objectstack/service-analytics": minor ---- - -feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) - - - -**BREAKING** in the answer sense, on every SQL face, landing in the launch -window as `minor` under the lockstep convention this cluster's siblings use. - -**The behaviour that GOES AWAY, by name: searching a date as a string.** On the -SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and -`driver-turso`'s local transport — a `Field.date` / `Field.datetime` / -`Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator -matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 -row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; -`{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three -now return nothing**, and their `$notContains` mirrors now return every valued -row. If you are relying on any of them, this is a row-set change and the -replacement is a range filter — spelled out below. The behaviour was never -declared by any contract row and it never worked outside SQLite: the same three -filters were a `DATABASE_ERROR` 500 on live Postgres. - -Nothing that was refused becomes admitted, and no new error code is minted — the -refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the -numeric and boolean classes. - -Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: -「a text operator over a column whose DECLARED type is temporal is type-gated -exactly like the numeric and boolean classes; the SQLite ISO-text match is not -a contract」. - -## What was wrong — one filter, three answers across one driver family - -`{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding -`2026-01-05`: - -| face | before | mechanism | -|:--|:--|:--| -| `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | -| `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | -| `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | - -Three answers to one filter, and no face declared which was canonical. The -SQLite answer was the accident of a storage form, not a capability: the same -query against Postgres was a 500. - -## What it does now - -The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` -(`@objectstack/spec`), the set the SQL compilers consult at compile time -because the stored value is not visible until run time. Every face that reads -it — `SqlDriver` (and everything that inherits its compiler), -`driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — -compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / -`$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to -the TRUE constant. Postgres's 500 becomes that declared answer; complementarity -holds; the constants compose with the existing NULL-safe rules and the `$not` -rewrite unchanged. - -**The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask -for "records in 2026" writes a range instead, which every dialect has always -answered the same way: - -```ts -// before — matched only on the SQLite family, 500 on Postgres -{ on_day: { $contains: '2026' } } -// after — the prescription, identical on every backend -{ on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } -``` - -## Boundaries, so a reader does not over-read this - -- **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON - TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on - a JSON column — not a substring test. It keeps compiling exactly as before. -- **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not - caveated.** `driver-memory` canonicalises a declared temporal write to ISO - TEXT (#4047), for a `Date` input and a string input alike, so a positive text - operator MATCHES there — the exact complement of the answer this changeset - declares. That divergence is filed as #17348 and pinned by name in that - driver's conformance suite, alongside a correction: the two rows previously - read as pinning the no-match answer pass because their comparand omits the - milliseconds, not because anything type-gates. `formula` and `having` cannot - key on the declaration at all — `matchesFilterCondition(record, filter)` takes - a bare record ("this evaluator sees a bare record and has no schema to - consult", its own docblock), and `having` filters AGGREGATED rows whose columns - carry no field declaration. ⛔ So "on every face" is NOT delivered by this - change, and this changeset does not claim it: the SQL family answers the - declared rule, the JS faces do not yet. -- **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there - is keyed on the STORED value — which is why its non-string column is a number - and not a date — so a temporal fixture would assert one stored form across all - five drivers that import it, the stored-form guarantee the ruling refused - option (b) for. -- **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its - cell rests on the compiled-shape pin, which reads the constant a statement - would carry without executing one. diff --git a/.changeset/translate-flow-walks-adr-0031-regions.md b/.changeset/translate-flow-walks-adr-0031-regions.md deleted file mode 100644 index 31fba9e49b..0000000000 --- a/.changeset/translate-flow-walks-adr-0031-regions.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth - -`translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and -nothing else. But `FlowNode.config` carries ADR-0031 regions — -`loop.config.body`, `parallel.config.branches[].nodes`, -`try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests -arbitrarily, and a `type: 'screen'` node inside one is a real screen: the -executor pauses on it and the client receives its `ScreenSpec.nodeId`. - -So `flows..screens..{title,fields.*}` was authored for such a -node, parsed (the bundle schema is keyed by node id and knows nothing about -depth) and was then silently never applied. The wizard step rendered its -source-locale heading and field labels while its siblings one level up were -translated. - -The descent now runs through `mapFlowNodeList`, a per-flow region-aware -copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which -reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region -lives (`automation/region-slots.ts`). This resolver is therefore not a fifth -hand-rolled reader of that table; the fourth pass written against the flat -one-liner is the last one that had to be. - -Reference identity is unchanged and is pinned: a node that resolves nothing -comes back as the same reference, every container `config` and region `nodes` -array on the way down is copied only when a descendant actually changed, and a -flow the bundle does not carry is returned as the same object. - -⛔ No wiring changed. `translateFlow` is still deliberately absent from -`translateMetadataDocument`'s dispatch table and no liveness row moved — that -decision belongs to the downstream runner card, as its docblock records. diff --git a/.changeset/two-factor-verify-echoes-live-user-row.md b/.changeset/two-factor-verify-echoes-live-user-row.md deleted file mode 100644 index f6a18df22c..0000000000 --- a/.changeset/two-factor-verify-echoes-live-user-row.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-auth": patch -"@objectstack/client": patch ---- - -`POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. - -On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. - -`two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. - -- **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. -- **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. -- **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. - -`@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. diff --git a/.changeset/validate-refuses-blank-structural-condition.md b/.changeset/validate-refuses-blank-structural-condition.md deleted file mode 100644 index ed1b732af5..0000000000 --- a/.changeset/validate-refuses-blank-structural-condition.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) - - - -**BREAKING** in the accept-set sense, landing in the launch window as `minor` -(the lockstep convention: `major` is refused by `check-changeset-no-major`, and -breaking-ness is carried by this banner plus the ADR-0087 disposition): -`validateStackExpressions` — the pass behind `objectstack validate` — now -reports an `error` for a structural `condition` whose source is blank after -trimming. It reported nothing at all before. - -The value was already refused by two of the three doors. `FlowEdgeSchema.condition` -composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is -refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to -that same rule, so the same value on a node's `config.condition` stops the flow -registering. `objectstack validate` was the door that still said nothing — so an -author got a clean bill, deployed, and the flow never registered: each boot path -in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one -`warn` naming the flow, and continues. On a `start` node that key is the -**trigger gate**, so the whole flow is armed by nothing. - -FROM → TO, for a build that used to pass and now fails: - -```yaml -# FROM — validate said nothing; registerFlow refuses it at boot -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } - -# TO — either write the predicate you meant… -nodes: - - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } - - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } - -# …or drop the key. An ABSENT condition is still not a malformed one: a start -# node with no `condition` is an ungated trigger, and that is unchanged. -``` - -The refusal is the edge door's own sentence, not a second one — the finding -carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and -slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all -three doors now ask one imported schema. - -Unchanged, deliberately: the **evaluator**. A condition already stored blank -still answers `false` at run time — #15662's ruling on that half stands. What -moved is that it can no longer be authored past validate. diff --git a/.changeset/value-domain-note-settings-door-repointed.md b/.changeset/value-domain-note-settings-door-repointed.md deleted file mode 100644 index 7ef1135207..0000000000 --- a/.changeset/value-domain-note-settings-door-repointed.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" - -The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written -while the re-point was still in the future: - -> The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in -> its own follow-up card and is unchanged until then. - -Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine -half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence -forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the -door it names had already re-pointed, one commit earlier. - -The sentence now says what is true of that door, read off its source rather than off a PR title: -its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks -`isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side -is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is -walked, the fragments the env-override log line needs). A re-added local table reddens -`value-domains.shared-predicate.pin.test.ts`. - -Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and -`liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which -move here (`check:liveness` reports the counts file current). diff --git a/.changeset/value-envelope-nullish-attribution.md b/.changeset/value-envelope-nullish-attribution.md deleted file mode 100644 index 5b75dc91d0..0000000000 --- a/.changeset/value-envelope-nullish-attribution.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) - -`AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. - -Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. - -Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. - -**Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. diff --git a/.changeset/verify-in-process-handle.md b/.changeset/verify-in-process-handle.md deleted file mode 100644 index 1e23f2eff5..0000000000 --- a/.changeset/verify-in-process-handle.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/verify": minor ---- - -**Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. - -Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. - -New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): - -- `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). -- `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. -- `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. -- `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. -- `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). -- `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. -- `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). -- `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. - -Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. - -Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. - -**Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. - -No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. diff --git a/.changeset/visiblewhen-app-scope-root-prose.md b/.changeset/visiblewhen-app-scope-root-prose.md deleted file mode 100644 index 5c0991aa42..0000000000 --- a/.changeset/visiblewhen-app-scope-root-prose.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) - -Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. - -The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: - -- `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). -- `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. -- `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. - -Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). - -No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 04b848ed3c..c837fccd5b 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -74,7 +74,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:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -92,7 +92,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:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -143,7 +143,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -161,7 +161,7 @@ image)? The official image is nothing more than: ```dockerfile title="Dockerfile (self-built runtime, equivalent)" FROM node:22-slim -RUN npm install -g @objectstack/cli@17.4.0 +RUN npm install -g @objectstack/cli@17.5.0 WORKDIR /srv/app RUN chown node:node /srv/app diff --git a/content/docs/releases/index.mdx b/content/docs/releases/index.mdx index 2240dfba24..10ae7504e4 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 -- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.4.0, released 2026-09-09). +- [v17.0.0](/docs/releases/v17) — Files become owned `sys_file` records with server-enforced `accept`/`maxSize` and a governed download path, bulk export becomes its own opt-in privilege, the SDK is reconciled against the routes the server actually mounts (21 dead methods out, 40+ real ones in), approval nodes route approvers dynamically via CEL expressions and decision outputs, a datasource that cannot connect fails the boot, and Node 22 becomes the supported floor; 17.1 adds partial field masking, record-view auditing on `sys_audit_log`, and a per-object read-only approval visibility tier — and makes a deactivated permission set or position actually stop granting access, withdraws the bulk-export wildcard from the shipped admin sets, and gives all three flow doors one honest HTTP status table; 17.2 tightens by-id `update`/`delete` against a silently-dropped `where` predicate or a mismatched id, retires `sys_position.permissions` and other dead ADR-0049 surfaces, and stops analytics from answering the wrong number on a cross-object filter (current series: 17.5.0, released 2026-09-11). - [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). - [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (final release: 14.8.0). diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 3ec0727d73..a06a96740c 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -51,7 +51,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:17.4.0 +image: ghcr.io/objectstack-ai/objectstack:17.5.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 75bd9cf7dc..80da9e3880 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:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.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:17.4.0 + ghcr.io/objectstack-ai/objectstack:17.5.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:17.4.0 +FROM ghcr.io/objectstack-ai/objectstack:17.5.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=17.4.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.5.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 0c0788285c..600aea9bd7 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/example-crm +## 4.0.97 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [1a25f4a] +- Updated dependencies [2eb4724] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 9d435c6b31..9553fea06b 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.96", + "version": "4.0.97", "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 ad88a3a4ff..6ad2a828e0 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/example-multi-package +## 0.0.4 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 0.0.3 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 4955290209..107d3a5f26 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.3", + "version": "0.0.4", "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 fdef41f669..8bebbaa20a 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,94 @@ # @objectstack/example-showcase +## 0.3.19 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [1a25f4a] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [ea4d164] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/connector-slack@17.5.0 + ## 0.3.18 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 218e82bb24..e4a589e4a6 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.18", + "version": "0.3.19", "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 56769e423a..23cdb8814d 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,111 @@ # @objectstack/example-todo +## 4.0.97 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [f19dbcf] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [1a25f4a] +- Updated dependencies [2eb4724] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/knowledge-memory@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 4.0.96 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index afc4423a01..43fffde5e6 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.96", + "version": "4.0.97", "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 13626bd789..853473fba9 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,87 @@ # @objectstack/example-embed-objectql +## 0.0.37 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/objectql@17.5.0 + ## 0.0.36 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index e60ead7f02..736c48b957 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.36", + "version": "0.0.37", "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 7e02a1a80a..11021d8c42 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,27 @@ # @objectstack/hono +## 17.5.0 + +### Patch Changes + +- Updated dependencies [cea85fd] +- Updated dependencies [1a25f4a] +- Updated dependencies [76ddab7] +- Updated dependencies [ea4d164] +- Updated dependencies [c3ebe4a] +- Updated dependencies [cefe068] +- Updated dependencies [288fe9c] +- Updated dependencies [6e3462d] +- Updated dependencies [331a1a2] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [0ced0aa] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] + - @objectstack/runtime@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 389dc2f5e1..4fd636ad50 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.4.0", + "version": "17.5.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 8b2c00325d..a6917d620d 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/account +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index db70f15728..746221f765 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.4.0", + "version": "17.5.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 a1a5027d1e..d4ec68e8e6 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/setup +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index fc4866f62d..4eba739f61 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.4.0", + "version": "17.5.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 f3fe0e382c..1d3ba2c3f5 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/studio +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index a18b7881a6..b88d2549e9 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.4.0", + "version": "17.5.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 36d8ee67c7..ec46fcd157 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1116 @@ # @objectstack/cli +## 17.5.0 + +### Minor Changes + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d0f06ff: feat(cli)!: `os generate` refuses a metadata name outside the charset `packages/spec` declares for an object `name`, before it derives anything from it (#16726) + + Maintainer ruling, decision batch #82 (2026-09-08), option A — **a gate, not a sanitiser**. `os generate ` used to accept any name at all; since #16724 it has refused names whose emitted TypeScript does not parse. It now also refuses, ahead of that check and ahead of every derivation, any name the object-`name` declaration in `@objectstack/spec` rejects. The refusal names the value and quotes the schema's own rule, and writes nothing. + + ⛔ Nothing is rewritten. The rejected alternative was to derive a legal identifier the way `os create` does, which decouples the name the author wrote from the name that gets emitted with nothing announcing it — the failure mode that multiplies silently when metadata is written in bulk. So the name you author and the name that lands in the file are always the same string. + + **What this narrows:** kebab-case (`order-line`), uppercase (`Order`), dotted (`foo.bar`) and digit-initial (`2fast`) names were accepted before and are refused now — `order-line` used to generate `order_line.object.ts` binding `orderLine`. Write the snake_case name directly (`os g object order_line`). ⛔ No new charset was minted and no flag bypasses the gate; #16724's parse check is unchanged and stays as the backstop behind it (`class` passes the charset and is still refused for `object`, because `const class:` is not a declaration). + + +- e2c2620: fix(cli): `os i18n check` counts the coverage an app actually owns, so `--strict` / `--threshold` can gate an app package (#16681) + + ## What was wrong + + `collectExpectedEntries` walks the Studio metadata-form registries + unconditionally — identically for every config, an empty one included — so + every stack's expected set carries ~773 `metadataForms.*` keys that + `@objectstack/platform-objects` translates and the runtime already serves. + + Two of the three commands that see that family already knew it is not the + author's. `os lint` hides it and says so ("platform built-ins: 773 i18n + issue(s) hidden — rerun with `--include-platform`"); `os i18n extract` has + `--no-metadata-forms`. `os i18n check` is the one command that publishes a + **percentage**, and it carried the baseline in its denominator: + + ``` + Coverage by locale + en ████████████████████████ 100.0% (1265/1265, missing 0) + zh-CN █████████░░░░░░░░░░░░░░░ 38.9% (492/1265, missing 773) + ``` + + That is an application with every key it owns translated. `--strict` and + `--threshold` — the two flags whose entire purpose is CI gating — therefore + could not gate an app package at all, and the only way to move the number was + to ship a copy of the platform's bundle, which would *override* the platform's + own and go stale at the next upgrade. The workaround was worse than the defect. + + ## What it does now + + **Ownership is observed, not assumed.** The baseline counts toward coverage + when the stack under examination ships those translations itself, and does not + when it does not — read from the config's own `translations` bundles, requiring + a non-empty string leaf so an `--fill=empty` scaffold is not mistaken for a + claim of ownership. An app gets a number about its own surface with no flag; + `platform-objects`, which does ship the family, stays gated on it with no flag + either. An unconditional exclusion would have turned the app side green by + deleting the platform's own gate, and is what the negative-control tests forbid. + + **The flag is `os lint`'s, spelling and all.** `--include-platform` forces the + baseline in; `--no-include-platform` forces it out, for a package that ships a + partial baseline and does not intend to own the rest. Absent, the decision is + the observed one — three states, not two. + + **Both output faces carry the decision.** `--json` gains + `platformMetadataForms: { mode, excludedKeys }`, and the console prints + `platform built-ins: N key(s) not counted — rerun with --include-platform to + gate them here` under the coverage table, rendered from those same two numbers. + + `os lint` is unchanged. The shared `computeI18nCoverage` seam still counts the + baseline by default, because lint folds it away one seam later and counts what + it folded for its own hint line. + + ## Compatibility + + Additive on the command surface; an invocation that was refused is now + accepted, and no flag is removed or renamed. The behaviour that changes is the + **default coverage number for a stack that ships no `metadataForms` bundle** — + it stops reporting a debt that stack must not pay. A run that wants the old + numbers back asks for them with `--include-platform`, on the same argv. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- fb39b38: fix(cli): `os migrate meta --from N` — the invocation every tombstone prescribes — lists the conversions it was sent to list, and an empty range stops reading as success (#17134) + + `--to` defaulted to `PROTOCOL_MAJOR`, the major the runtime implements. But retirements land throughout a major's line, and their ADR-0087 conversions are registered under the NEXT one: `@objectstack/spec@17.4.0` tombstones `dashboard.refreshInterval` while the conversion that renames it is `toMajor: 18`. The `retiredKey()` house sentence names the major the source was **authored** against — `Run \`os migrate meta --from 17\` …` — so the prescribed invocation composed the range `17 → 17`, which `composeMigrationChain` selects **no step** for, and the command answered: + + ``` + ✓ Nothing to migrate — the metadata is already canonical for this range. + ``` + + exit 0, printed immediately under the five refusals that named that exact command. **29 shipped tombstones across 15 source files prescribe it.** + + Two changes, both in `packages/cli`: + + - **`--to` now defaults to the highest major this build of `@objectstack/spec` carries a migration step for** (`Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`), so the tombstone template's presumption holds in every window rather than only after the next major has shipped. Nothing is migrated "past" the runtime: every registered conversion maps a shape the installed schemas already **refuse** onto the one they accept, which is why the terminus is the only target for which the command's own `schemaValid` verdict is reachable. `Math.max` keeps the runtime's major as the floor for the reverse case. + - **A range holding no step is answered as one.** `already canonical` was a green verdict on a check that never ran, so the empty-range case now says so, names the range that would list the conversions (`--to N`), and no longer returns past the schema verdict that contradicted it — the same run used to report `schemaValid: false` in `--json` while the human output claimed the metadata was canonical and stopped. + + **What changes for you.** `os migrate meta --from ` with no `--to` now replays one hop further than it did, so a cross-major run prints that hop's semantic TODOs as well — the same wall a `--from N-1` run has always printed, one major on. The mechanical rewrite list is still first. `--to` is unchanged when you pass it, `--stored` is untouched, exit codes are unchanged (this command reports findings, it does not exit on them), and a range that holds real steps and rewrote nothing still answers `Nothing to migrate`. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 87ad73b: + + feat(cli)!: the `--json` payload key `specVersionGap` is renamed to `protocolVersionGap` (#14261) + + **BREAKING** — a published machine surface changes a key name. `os validate --json` and + `os build --json` emit **`protocolVersionGap`** where they emitted `specVersionGap`. A + consumer reading `specVersionGap` reads `undefined` after this release and must switch to + the new name. There is **no alias and no dual-key transition window**: one axis, one name. + + The value shape is unchanged — `null` when the app's declared compatibility range admits + the installed `@objectstack/spec`, otherwise the same advisory record with the same + members. Nothing else on either payload moves: no other key is added, removed or + reshaped, and the text faces of both commands are byte-identical. + + ## Why the name had to move + + The axis this advisory reports moved in **#13860**: it used to read the undeclared + `manifest.specVersion` and now reads `manifest.engines.protocol`, which is declared + (`PluginEnginesSchema`), stamped by every scaffold, and enforced at boot. The published + key name stayed behind for one release, deliberately — renaming a machine face with + pinned consumers is a break, and no ruling covered it at the time. + + Leaving it is a correctness problem, not untidiness. A key spelled `specVersion*` invites + the reader — an AI agent above all — to infer that a writable `manifest.specVersion` + exists. `ManifestSchema` is not `.strict()` and **silently drops unknown keys** (#14192), + so acting on that inference does not produce an error: it produces a manifest that looks + entirely normal and whose `specVersion` line never took effect. That is the same + ghost-key breadcrumb mechanism that caused #13860 in the first place, left standing on + the output side. + + ## What a consumer should do + + ```diff + - if (payload.specVersionGap) { … } + + if (payload.protocolVersionGap) { … } + ``` + + The breaking surface was measured before the rename and is closed inside this repository: + the only consumers of the old key were three in-repo e2e suites, which move in this same + change; **zero external consumers were found**. Graded `minor` by the maintainer's + explicit grading of 2026-09-02; the banner above carries the breaking-ness the level + cannot. +- 0aa88eb: `os package publish` no longer publishes under a manifest id the author did not write. A `manifest.id` the artifact declares is now used or refused — never silently swapped for a derived one. + + Before this, `deriveManifestId` adopted `manifest.id` only when it parsed as `PackageSchema.manifestId`, and any other declared value fell through to `local.`. Nothing said so: the substituted id appeared in the ordinary progress line, byte-identical to the run where the artifact declared no id at all. + + ``` + manifest.id = 'crm' before: → Registering package 'local.acme-crm'... (exit 0) + manifest.name = 'Acme CRM' + after: ✗ Invalid manifest-id 'crm'. … (exit 1) + ``` + + `sys_package.manifest_id` is **immutable once set** — "renaming a package requires creating a new package" — so the value chosen there is a permanent, globally unique identifier. Choosing it silently, against the author's own declaration, is the one field that must not be rewritten without a word. + + - **A declared `manifest.id` reaches the existing preflight gate.** If it is not a manifest id the control plane accepts, the publish refuses before any network call, quoting the schema's own issue and description and naming where the id came from. No second rule is introduced in the CLI: the judgement is still `PackageSchema.manifestId`, which is the same schema node `CreatePackageRequestSchema.manifestId` declares for the `manifest_id` this command POSTs. + - **Honouring the declared value instead was not available.** The values that used to fall through are, by construction, exactly the ones that schema rejects, so forwarding one would only move the same refusal to the server, later and with a worse message. + - **Absent, blank and non-string `manifest.id` are unchanged** — none of those is a declaration, and each still derives from `manifest.name`, then the artifact filename. + + What to do if a publish that worked now refuses: the message names the three ways out. Fix `manifest.id` in `objectstack.config.ts` to a reverse-domain id and rebuild; remove the key to keep publishing under the derived `local.…` id (the value the previous release was already using); or pass `--manifest-id`. Every id the control plane accepts publishes with unchanged bytes. + +### Patch Changes + +- f721ef0: fix(cli): the boot banner's `🔑 Dev admin` says what that account will and will not see (#17081) + + `--seed-admin` (on by default in `os dev`) prints one credential, and it is the + **only** one a first-run operator is given. It is also, by construction, the + account with every *platform* capability and no *app-declared* one: its standing + is `admin_full_access`, whose `systemPermissions` are `setup.access`, + `studio.access`, `manage_users`, `manage_metadata`, `manage_platform_settings` + and `manage_sharing` — all platform built-ins — plus the `'*'` + view-all/modify-all record bits. + + So in any app that gates its apps, tabs or nav entries on + `requiredPermissions` — the filter `/me/apps` and `/meta/app` apply, and a + first-class platform feature the docs teach — the credential the terminal hands + over is the account that resolves to an **empty navigation**. A downstream + maintainer ran `pnpm dev`, signed in with it, and read the empty shell as a + broken product. The app was correct. The banner had asserted a login and said + nothing about its audience, and it outranks whatever the app's own README says, + because it sits directly under the command that was just run. + + FROM → TO, on a boot that seeds: + + ``` + 🔑 Dev admin: admin@objectos.ai / admin123 + seeded on empty DB · dev only — do not use in production + + platform admin — Setup, Studio and every record, but NO app-declared capability, so + + an app that gates navigation on requiredPermissions may show it an empty menu; grant + + it a permission set under Setup → Users, or sign in as an account your app seeds + ``` + + **Nothing about the seed changes.** What the first run creates — the account, + its address, its password, its promotion to platform admin — is a product-shape + decision and is untouched; only the banner's words move. The three lines print + only inside the branch that already prints the credential, so a boot that seeds + nothing is byte-identical to before. + + Dim continuation lines rather than a warning, deliberately: ADR-0115's + `OS_ALLOW_DEV_PLUGIN` amendment excluded the dev-admin seed from that hazard set + because "a warning about a non-event spends the attention the real ones need". + That exclusion is kept — this qualifies an event that just happened, on the line + that already announces it, and adds no new line where there was none. + + The route the sentence names is asserted against the declarations that make it + reachable, not re-spelled: `SETUP_APP.requiredPermissions` is a subset of what + this account holds, the `Users` entry is ungated, and the `sys_user` detail page + carries the "Grant permission set" related list. A rename on any of those reds + the pin instead of leaving the banner pointing at nothing. +- d07fc17: `os i18n extract` reaches a `screen` node nested inside an ADR-0031 flow region + + `walkScreenFlows` (`packages/cli/src/utils/i18n-extract.ts`) iterated + `flow.nodes` flat, so a `type: 'screen'` node inside a region — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try` / `.catch`, nesting arbitrarily — was never reached. It + emitted **no** `flows.NAME.screens.NODE_ID.title` / `.fields.*` skeleton entry + and **no** coverage row. + + **Why that pairing is the defect and not just a missing translation.** A nested + wizard step is a real screen: the executor pauses on it and the client receives + its `ScreenSpec.nodeId`, so `translateFlow` overlays the bundle onto it and the + key is live. With no entry emitted, a translator was never shown the key AND + `os lint` / `pnpm check:i18n-coverage` had no row to demand — the gap was + invisible to the mechanism built to report gaps. A green i18n gate on a tree + whose nested steps render source-locale text was green because the surface was + unreachable, not because the app was translated. + + The node universe now comes from a region-aware descent that reads the one + shared declaration of WHERE a region lives, `FLOW_REGION_SLOTS_BY_TYPE` from + `@objectstack/spec/automation` — the same table `packages/lint`'s + `walkFlowNodes` reads. No local copy of the slot list is introduced: a second + region table in a fourth package is the very shape this defect is an instance + of. + + **Depth deliberately does not enter the key.** Entries stay + `flows.NAME.screens.NODE_ID.*` at every depth, because `lookupFlowScreenCopy` + is keyed by node id alone and the bundle schema knows nothing about depth; a + region path segment would offer a key nothing resolves. A node id repeated at + two depths therefore addresses one bundle slot and collapses to a single entry + (first emission wins, outer before inner) — one slot can serve only one string, + and the resolver overlays that string onto both nodes. + + Seeding is unchanged and applies at every depth: a screen `title` falls back to + the node `label` (what `ScreenSpec.title` draws), and a field `label` falls back + to its `name` as a *derived* seed, so the skeleton stays usable while the + coverage gate demands no translation of a string nobody authored. + + ⛔ No authorable key, bundle shape or export moves — an author who wrote a + nested screen now gets scaffolding and a coverage row where both were silently + absent. Existing keys are byte-unchanged. +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. +- 010c48a: fix(cli): `os register` requires a name, and the request-side `as any` that hid the mismatch is gone (#16932) + + `os register` prompted **"Name (optional)"**, typed its own payload with `name?`, and guarded `email` and `password` but not `name` — three places agreeing the field was optional. The route it actually posts to does not agree: on a fresh environment (no human user yet, so the audience gate's bootstrap bypass admits the request and the route's own validation is the only judge left), `POST /api/v1/auth/sign-up/email` answers `400 VALIDATION_ERROR` — `[body.name] Invalid input: expected string, received undefined`. The same run with a name supplied answers `200` and creates the account. + + So the first-use path failed on exactly the answer the prompt invited, and `RegisterRequestSchema`'s required `name` was right all along. + + - the prompt now reads `Name: `; + - an empty answer is refused by the CLI itself (`Name is required`), beside the existing `Email is required` / `Password is required` guards, before any request goes out; + - the payload is annotated with the declared `RegisterRequest` instead of a hand-written twin; + - the `as any` at the call site is removed, so the next divergence between this command and the declared request type is a compile error rather than a `400` a user meets on their first command. + + No behaviour change for anyone already passing a name, by flag or at the prompt. +- df8a16d: fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547) + + Every `--json` face in this CLI declares that it answers an error path with a + payload. `resolveConfigPath()` was the one path that bypassed that declaration: + it wrote its refusal and then called `process.exit(1)` **directly**, so nothing + was thrown and the catch-all each command already carries — all of which sit + downstream of a throw — never ran. Ten published faces answered a missing config + file with an empty stdout. + + Measured before this change on the published entry `packages/cli/bin/run.js`, + `NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces + (`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` · + `migrate meta` · `validate` · `verify`) across both branches of the helper, 19 + runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B + (auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the + same 19 runs answer **exit 1 with a parseable document on stdout**, stderr + unchanged byte for byte. + + The refusals now throw `ConfigRefusalError`. That is not a new contract — it is + this path being pulled back onto the one its callers had already published, so + it adds **zero** accept-set members and **zero** error codes. + + Three properties hold it in place: + + - **No face becomes a crash dump.** `os verify` had no `try` at all — measured, + a throw through it produced an oclif error line and no payload where every + sibling emitted an envelope — so it gains the catch-all its nine siblings + already had, in this same change rather than after it. + - **The text face does not narrow.** The refusal and both hint lines are still + written by the helper, to stderr, byte-identical: all 19 non-`--json` runs + compare equal before and after on stdout, on stderr and on exit status. The + catch-alls skip re-rendering the sentence a second time on stdout. + - **No error code is minted.** The thrown error carries neither `code` nor + `httpStatus`, so `errorCodeFields()` contributes nothing and each face emits + its own bare `{ error }`. Whether that shape is right is **#15549**'s open + question, and this change deliberately does not answer it. + + The `--json` stdout-purity instrument is widened with the fix rather than after + it: the pre-boot family's discovery moves into a shared module, the pin that + drives it now demands a document (empty stdout no longer passes) and compares + the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts` + — whose own discovery is `bootSchemaStack`-based and cannot see a command that + fails above the kernel — reconciles against that population so neither half can + be lost silently. +- 3c5f3c5: fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) + + ## What was wrong + + Both migration formats emitted the table and none of the object's declared + uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all + three producers into three schemas, `pg_indexes` read back per schema: + + ```ts + { name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } + ``` + + | producer | before | after | + |:--|:--|:--| + | `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | + | `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + | `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | + + Two rows with the same `keyed_unique` value were refused by the platform's table + (`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted + by both generated ones, with nothing reporting it: a scaffold that creates the + table for an object silently dropped a uniqueness guarantee the object declares. + After the change the duplicate is refused by all three, each naming the same + constraint. + + The key set was not missing — it was already computed here to size the keyed + text family's columns; only the index it implies was never emitted. + + ## What it does now + + - **`--format sql`** emits an inline `CONSTRAINT "" UNIQUE ()`. + That is what knex's `table.unique(columns, { indexName })` — the driver's own + call — compiles to on PostgreSQL, so a generated table and a platform-created + one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside + the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD + CONSTRAINT` has no spelling for. + - **`--format ts`** emits that knex call itself, `indexName` included — which is + what makes the driver recognise the constraint as already present on its first + boot against a generated table, instead of adding a second one under its own + name and then reporting the generated one as an orphan to drop. + - Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned + against the driver's own export (a CLI production module may not statically + value-import a driver package). + + ## What it deliberately still does not emit — and now says so + + Both formats print a `NOT EMITTED:` line naming the index, its key parts and the + reason, instead of dropping it silently: + + - the **organization-scoped composite** (`unique: true` / `'organization'` on an + object with an organization column), whose key part is + `COALESCE(, '__global__')`. Emitting the bare composite + instead would be worse than emitting nothing: under SQL's NULL-distinct + `UNIQUE` it constrains no row that has no organization, which on a + single-tenant deployment is every row. + - an index over a column no field materialises (a virtual `formula` field) — + the same skip the driver performs, where the driver logs a warning. + + Object-level `indexes[]` remains unemitted by both formats; it is normalized by + a different driver-side rule and is not covered by this change. +- 559e531: fix(cli): a generated migration carries the column DEFAULT `driver-sql` puts on the same field (#16294) + + ## What was wrong + + Neither `os generate migration` format read a field's `defaultValue`, so a table + created from a generated migration had no column DEFAULT where the platform's + own table has one. A row inserted out of band — by a database client, a seed + script, anything that does not go through the engine — got NULL where the + declared value belonged. + + Driven on live PostgreSQL 16.13: one object, three schemas, one producer each + (`driver-sql` through `initObjects`, `--format sql` through `db.raw`, + `--format ts` by importing the emitted module and calling `up(db)`), with + `information_schema.columns` read back per schema. + + ``` + field driver sqlgen verdict + f_default null=YES default='hello'::text null=YES default=- DIVERGED + f_default_required null=YES default='hello'::text null=YES default=- DIVERGED + ``` + + After: `diverged: 0 of 6` on the card's probe, and 22 of 23 on a wider one + covering every `defaultValue` shape. + + ## What changed + + Both formats now render one shared verdict, taken from + `SqlDriver.applyDeclaredColumnDefault` — the single place a `defaultValue` + becomes DDL on the platform side: + + - a **literal** is emitted, quoted the way knex binds it (`DEFAULT '42'`, not + `DEFAULT 42` — PostgreSQL keeps those two textually apart forever in + `column_default`, and the driver's column carries the quoted form); + - **`'NOW()'`** becomes the driver's own translation, which is type-branched: + `CURRENT_TIMESTAMP` on a timestamp column, and a UTC-pinned expression on + `date` / `time`, because a bare `CURRENT_TIMESTAMP` resolves those in the + server's timezone; + - **any other runtime token** (`current_user`), an **Expression envelope** and + an **option-level `default: true`** emit nothing, each because the driver + emits nothing — the engine owns those, and a column DEFAULT would override a + decision it makes deliberately; + - a **`multiple: true`** field gets neither, because `createColumn` returns + before both questions. + + No authorable key, export or accepted-input set changes: `defaultValue` was + already declared, already parsed and already honoured by the driver. The + generators simply now read it. +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is + `seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; + that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and + whether it should go is tracked by #17167. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- 3a2d2b5: `os explain query` now teaches the two keys `QuerySchema` actually declares. + + The entry's example and its two optional-table rows named `filters` and `sort`. + Neither is a key of `BaseQuerySchema`, which is a plain `z.object` — so both + were dropped silently: an author who copied the example got a query that parsed + clean and ran with no filter and no ordering, with nothing in the output saying + so. + + Both faces now read the schema's own spellings: + + - `where` — one condition **tree**, not a `Filter[]`. A field-keyed entry is a + condition on that field (a bare value is implicit equality, an object is a map + of `$` operators), and `$and` / `$or` / `$not` combine conditions. + - `orderBy` — sort nodes, each `{ field, order }`. The direction key is spelled + `order`; `direction` is rejected by name. + + No schema changed, and no accept set moved: the correction is to the catalog + entry only. The `os explain` catalog sweep also gains a key-retention assertion + — an example must parse **and** come back with every key it declares — so the + next entry whose schema strips a key is named instead of passing. +- 6e3462d: `serve`: the multi-org runtime's stage-1 refusal no longer prints its own install remedy for a `declared-unresolvable` failure — it defers to the importer's message, which the same refusal already prints as its `cause:` line. + + Driven on both shapes that kind covers, the minted bullet ("Repair the INSTALL … run `pnpm install`, check that a production prune did not drop it, and that its dist is actually built") was wrong twice over. For a genuinely broken install it repeated, word for word, the three remedies the cause line four lines below already carried. For a location install the finder cannot tie to the declaration, the cause says outright that re-running `pnpm install`, un-pruning a deploy and rebuilding a dist all change nothing — so one screen contradicted itself. + + The arm now says only what it uniquely knows (the app DOES declare the package, so re-reading `package.json` will not help) and names the cause as the authority on the remedy — the same deferral the `declared-no-loadable-entry` arm has had since it landed. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- edaf3b2: `os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`. + + A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping. + + Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction. + + ⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic. +- 5865b02: `os create plugin` names the standalone scaffold `plugin-` and marks it `private` + + The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a + project scaffolded for a developer outside this monorepo — a scope they cannot publish + to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is + never resolved from a registry inside the project, so `pnpm install`, the type-check and + the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The + emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. + + The standalone default now emits: + + - `"name": "plugin-"` — unscoped, and the same string as the directory the + scaffolder prints and creates; + - `"private": true` — the line that actually stops an accidental publish, whatever the + name says; + - a README whose install instruction is a local reference (`pnpm add link:../plugin-`) + and whose import specifier matches the emitted package name. + + `os create plugin --in-repo` is unchanged: it still emits a publishable + `@objectstack/plugin-` with no `private` flag, because that placement lands under + `packages/plugins/` where every sibling genuinely carries that scope. + + No action is needed for a project already scaffolded. If you generated one with the old + name and have not published it, rename `package.json`'s `name` to `plugin-` (or a + scope you own) and update the README's install line; the exported symbol and the plugin's + runtime `name` are unaffected. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [e526556] +- Updated dependencies [216b066] +- Updated dependencies [f19dbcf] +- Updated dependencies [86c5052] +- Updated dependencies [7aae005] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [cea85fd] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [1a25f4a] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [690f083] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [86f4246] +- Updated dependencies [4ef8247] +- Updated dependencies [ea4d164] +- Updated dependencies [ab48938] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [f6b7c53] +- Updated dependencies [92865f6] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [6e3462d] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [f89dd33] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [470746a] +- Updated dependencies [e4fd55d] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [4062aef] +- Updated dependencies [6ff5b56] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [032452a] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [6058cb2] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - create-objectstack@17.5.0 + - @objectstack/client@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/cloud-connection@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/plugin-reports@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/service-cache@17.5.0 + - @objectstack/service-job@17.5.0 + - @objectstack/service-package@17.5.0 + - @objectstack/service-queue@17.5.0 + - @objectstack/service-realtime@17.5.0 + - @objectstack/service-sms@17.5.0 + - @objectstack/trigger-api@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + - @objectstack/plugin-pinyin-search@17.5.0 + - @objectstack/console@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d84eb3ea8..9f5d97667c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.4.0", + "version": "17.5.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 bd557777a0..5725c29f06 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,87 @@ # @objectstack/client-react +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [d61139f] +- Updated dependencies [1c4270f] +- Updated dependencies [f904e61] +- Updated dependencies [5de9372] +- Updated dependencies [f8fea00] +- Updated dependencies [fb6a2de] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [bccf311] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [032452a] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/client@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index d4bc7e15ea..b320e538db 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.4.0", + "version": "17.5.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 1eeb4b07a5..7d418304a4 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,323 @@ # @objectstack/client +## 17.5.0 + +### Minor Changes + +- d61139f: feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534) + + Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting: + + | route | where the new credential is | + | --- | --- | + | `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) | + | `auth.changePassword({ revokeOtherSessions: true })` | body — `token` | + | `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` | + + A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps. + + The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to. + + **No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers. + + ## What does NOT change, deliberately + + The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `.` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned. + + A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing. + + The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change. +- 1c4270f: feat(client): `environments.delete` gains `purge` and documents the hosted control plane's two-step delete (#17636) + + The hosted control plane's `DELETE /api/v1/cloud/environments/:id` follows cloud ADR-0014: a live environment is **archived**, and only a second call with `?purge=1` on the now-archived environment tears it down. `?force=1` confirms a production environment and is never a purge. The SDK sent `force` only, so an SDK caller could archive an environment but never purge one. + + - `opts.purge?: boolean` sends `?purge=1`. It combines with `force`: a production environment is torn down with `{ force: true }`, then `{ force: true, purge: true }`. Calls that pass no options, or `force` alone, build exactly the URL they built before. + - The return type declares the two answers the route actually sends, discriminated by `deleted`: + - archive: `{ environmentId, deleted: false, archived: true, purgeDeferred, retentionDays, warnings, message }` + - teardown: `{ environmentId, deleted: true, purged: true, warnings }` + + Both members carry every key the old declaration named (`deleted`, `environmentId`, `warnings`), so existing reads still compile. + - The JSDoc no longer describes a one-call cascade delete: a live environment is archived, `purge` acts only on an archived environment, `force` is the production confirmation, and a `failed` environment is torn down in one call. + - `organizations.delete`'s JSDoc no longer claims that server-side hooks tear down the organization's environments. No hook does; delete each environment first. + + Graded `minor`: a purely additive widening of a published method's accepted options and declared answer (the "WHICH LEVEL" rule in `.github/workflows/pr-automation.yml`). Nothing is removed or renamed. +- f904e61: fix(client): `organizations.getActiveMember(organizationId)` answers the organisation the caller NAMES, not whichever one the session has active (#16568) + + **BREAKING** — the answer this published method gives moves for existing inputs. The signature, the declared return type and the export are byte-identical; what changes is the response an existing call observes, stated below as a before/after pair per input. + + The method built `GET /organization/get-active-member?organizationId=…`, and better-auth 1.7.2's handler for that path reads `session.session.activeOrganizationId` and never looks at `ctx.query`. The query string was dead on arrival: a client doing a permission check for organisation B while A was active got **A's** membership row back, with a 200 and no diagnostic — the wrong-but-plausible answer, silently. The SDK's own JSDoc promised "the calling user's membership row in the given organisation", so this was a declared capability the runtime did not deliver. + + It now asks the question honestly, in two requests: + + 1. `GET /get-session` — the caller's own user id; + 2. `GET /organization/list-members?organizationId=…&filterField=userId&filterValue=&limit=1` — the row, unwrapped from the one-entry page. + + `list-members` reads `ctx.query.organizationId`, and its rows carry the identical shape (`OrganizationMemberWithUserWire`, user projection included), so the signature and the declared return type are unchanged and no caller's types move. + + ## What an existing call observes, before and after + + Everything here is measured against a real `AuthManager` (better-auth 1.7.2, organization plugin) over a real `SqlDriver`. Each bullet is one input, with the response it drew before and the response it draws now. + + - **An organisation id other than the session's active one.** Before: a 200 carrying the **active** organisation's membership row, whatever id was named. After: a 200 carrying the **named** organisation's row. An input that named the active organisation's own id drew that organisation's row before and draws the same row after — `auth.me()` is where that id is readable, on `session.activeOrganizationId`. + - **An organisation the caller is not a member of.** Before: the named organisation was never consulted, so the answer was about the **active** one — a 200 carrying the active organisation's row, or `400 MEMBER_NOT_FOUND` when the caller had no row there either. After: `403 YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION`, the server's own refusal, about the organisation that was actually named. + - **Any id, on a session with no active organisation.** Before: `400 NO_ACTIVE_ORGANIZATION`. After: a 200 carrying the caller's row in the named organisation. `setActive` has stopped being a precondition, which is the point of naming the organisation. + - **An empty `organizationId`.** Before: a 200 carrying the **active** organisation's row — better-auth resolves `ctx.query.organizationId || session.activeOrganizationId`, so an empty string fell through to session state and the wrong-but-plausible answer survived on that one input. After: the SDK refuses it before the wire, with a thrown `[ObjectStack] organizations.getActiveMember: organizationId is required`. + + Two things do not move: an anonymous caller still draws `401 UNAUTHORIZED`, thrown by the same session middleware that guarded the old route; and the row's shape is the same on both sides. The method now makes two HTTP requests where it made one. + + Graded `minor` rather than `patch`: the method's published behaviour moves for existing callers, which is the same clause-② judgement this PR declares, and the maintainer's ruling of 2026-09-04 (decision batch #35) holds that a change to a published package's public surface takes at least `minor` — a commit type may raise a bump, never lower it below what the act requires. The banner above carries the breaking-ness that the level cannot, per the ruling recorded on #16568 on 2026-09-08. + + The auth route ledger's `GET /api/v1/auth/organization/get-active-member` row is rebooked from `sdk` to `server-only` in the same change: `sdk` means "expressed by the SDK", and no SDK method builds that URL any more. The `get-session` and `list-members` rows gain the method in their notes, since it now builds both. Ledger-internal, nothing published moves with it. + + +- fb6a2de: fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034) + + `client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it. + + **Migration — read the row directly, not `.package`:** + + ```ts + // before + const { package: pkg } = await client.packages.get('com.acme.crm'); + const pkg2 = (await scoped.packages.get('com.acme.crm')).package; + + // after + const pkg = await client.packages.get('com.acme.crm'); + const pkg2 = await scoped.packages.get('com.acme.crm'); + ``` + + FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both. + + This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending. + + Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along. + + The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628. +- bccf311: fix(client): `oauth.applications.register` declares only the members `/oauth2/create-client` accepts — `name`, `scopes` and `metadata` are removed (#15447) + + **BREAKING** — three members leave a published request type. A caller who sets one compiles today and gets a type error after this release. That is the point: the route never honoured any of them, so what the compiler now refuses is code that was already having its value thrown away. + + ## What a caller passing these members should do instead + + | you were passing | pass instead | why | + |---|---|---| + | `name: 'My App'` | `client_name: 'My App'` | same `string`, and `client_name` is the member the route reads | + | `scopes: ['openid', 'profile']` | `scope: ['openid', 'profile'].join(' ')` | ⚠️ **not** a rename — `scope` is one space-delimited string; posting an array is refused with `400 [body.scope] Invalid input: expected string, received array` | + | `metadata: { tenant: 'acme' }` | nothing — delete the member | no door this SDK can reach accepts it (see below) | + + ## ⚠️ These were the vendor's RECORD vocabulary, not typos + + `client_name` writes the DB column literally named **`name`**; `scope` writes the DB column literally named **`scopes`**, as a JSON array. The removed members were the *column* names offered next to the *wire* names in the same declared type — an author picking the adjacent one of two got a success receipt and no value. Treating them as misspellings would be the wrong reading of what they were; the prescription above is still the wire member either way. + + ## Why they had to go rather than be honoured here + + `POST /api/v1/auth/oauth2/create-client` is mounted verbatim from `@better-auth/oauth-provider@1.7.2`. Its body schema declares 21 members and sets no `catchall`, so it is zod's default **strip**: an unknown key is dropped, not refused, and the caller gets **HTTP 201 and a client that quietly does not have the value**. Driven end to end against a real `betterAuth` + `oauthProvider` over a real ObjectQL engine on a real socket, through this client: each of the three came back absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, and `null` in the `sys_oauth_application` row. + + A second, independent barrier stands behind that strip — the handler funnels the parsed remainder into the opaque-metadata envelope, and all three names sit in `OPAQUE_METADATA_RESERVED_FIELDS` — so no amount of loosening on the SDK side could ever have made them arrive. `metadata` in particular is honoured only by `PATCH /admin/oauth2/update-client`, which is `SERVER_ONLY` and therefore not an HTTP route at all: over the wire it answers 404 with a zero-byte body. + + Nothing else on the method moves. The two members the route does honour, `client_name` and `scope`, are declared exactly as before and still reach the server byte for byte; the method's return type, its URL and its request-building step are unchanged. + + Graded `minor` rather than `patch` because a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch #35) that such a change takes at least `minor`; the banner above carries the breaking-ness the level cannot. + + + +### Patch Changes + +- 5de9372: fix(client): `auth.me` / `auth.refreshToken` deliver the `SessionResponse` envelope they declare, and `refreshToken` reads the token the route actually serves (#16760) + + Both methods annotate their return as `SessionResponse` — ObjectStack's REST + `{ success, data }` envelope — for `GET /api/v1/auth/get-session`. better-auth + owns those bytes and answers **bare**. Measured against a real `AuthManager` + (better-auth 1.7.2, organization plugin) over a real driver: + + ``` + GET /api/v1/auth/get-session (signed in) -> 200 {"user":{…},"session":{…,"token":"…"}} + GET /api/v1/auth/get-session (anonymous) -> 200 null + ``` + + So `(await client.auth.me()).data.user` type-checked and was `undefined` at + runtime, while `.user` — the real payload — did not type-check. The annotation + pointed every caller at the wrong key. + + ## What changed + + - The bare answer is now lifted into the declared envelope, the same lift + `auth.login` has always carried for `/sign-in/email`. `SessionResponse` is + **unchanged** and so is each method's published return annotation: the fix is + in what the methods produce, not in what they promise. + - The lift fills `success` as well as `data`. `SessionResponseSchema` is + `BaseResponseSchema.extend(…)` and that base declares `success` as a required + boolean, so a body carrying `data` alone still would not parse as the declared + type. + - The raw `.user` / `.session` keys are **kept** alongside `data`. They are what + callers were pushed onto while the declared shape was unreachable; dropping + them would trade one silent breakage for another. + - `auth.refreshToken` now reads `data.session.token`. It used to read + `data.data?.token` — a field this route does not produce at any nesting, so + the method returned successfully having captured nothing. A bearer-mode client + calling it to refresh kept whatever credential it already had, silently. + + ## The read was not a consequence of the envelope + + Worth stating because the reverse is the natural assumption: enveloping the body + does **not** put a token at `data.token`, because the route serves no top-level + `token` to lift. The only credential in the body is `session.token`, and that is + now the read. Fixing the shape alone would have left `refreshToken` exactly as + inert as it was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `(await client.auth.me()).user` | still works — kept deliberately | + | `(await client.auth.me()).data.user` | now populated (was `undefined`) | + | `(await client.auth.refreshToken(t)).data.token` | `.data.session.token` | + + `refreshToken` stores the **unsigned** session token, which is the spelling + `/get-session` serves; `bearer()` accepts it and the signed + `token.signature` form interchangeably, so a client that held the signed form + stays signed in across the call. + + Two answers stay outside the declared type and are **not** addressed here: the + anonymous `null`, which would need the published return annotation to widen, and + `SessionUser.image`, declared `z.string().optional()` against a route that + serves `null` (#17235). The sibling `auth.login` / `auth.register`, which + normalize into `data` but set no `success`, are #17234. +- f8fea00: fix(client): `organizations.invite` defaults `role` to `'member'`, so the shorter call it declares actually works (#16582) + + `organizations.invite` declares `role?` as **optional** and forwarded the caller's object to better-auth verbatim. better-auth 1.7.2's body schema for `POST /organization/invite-member` makes `role` **required**, so the documented-looking minimal call was refused before it reached any ObjectStack code: + + ``` + client.organizations.invite({ email, organizationId }) -> 400 [body.role] Invalid input (VALIDATION_ERROR) + ``` + + Omitting `role` now sends `'member'`. **No published type moves** — `role` stays optional, and a caller who names a role still gets exactly that role on the wire (including `role: undefined`, which is treated as omission rather than dropped). + + The default is `'member'` because the sibling `organizations.invitations.resend` has always substituted exactly that over the **same** vendor endpoint. That asymmetry is why the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. It is also the least-privileged name in the closed membership vocabulary (ADR-0108 D1 — `orgRoleGrade` floors at `member` and rises only for `owner`/`admin`), and an invitation is a pending row the invitee must still accept, so the implicit choice cannot confer reach the caller did not ask for. + + Measured against a real `AuthManager` (better-auth 1.7.2, organization plugin, `teams: { enabled: true }`) over a real `SqlDriver` (better-sqlite3), before and after: + + ``` + before: POST /organization/invite-member -> 400 {"message":"[body.role] Invalid input","code":"VALIDATION_ERROR"} + after: POST /organization/invite-member -> 200 {"role":"member","status":"pending", ...} + ``` + + No caller had to change: the census found no in-repo or Console caller using the two-argument form, so this repairs a path that was declared and unreachable rather than one that was in use. +- 032452a: fix(client): the scoped SDK reads `metadata.prefix` off the advertised routes instead of restating `/meta` + + `metadata.prefix` is a live `RestServerConfig` key: REST mounts every metadata + route under `metaPath = ${basePath}${metadata.prefix}` and the discovery handler + advertises the same value as `routes.metadata = ${realBase}${metadata.prefix}`. + Three surfaces describe one set of paths — the mounts, the discovery document, + and this SDK. + + `ScopedEnvironmentClient` restated `/meta` as a literal in all six of its + metadata methods — `getTypes`, `getItems`, `getItem`, `saveItem`, `deleteItem`, + `getHistory` — so on a deployment that moved the prefix, every one of them + called a path the server does not mount. The unscoped twin of each method was + already correct (it builds `${baseUrl}${getRoute('metadata')}`), so one SDK + disagreed with itself: the unscoped half read the advertised value while the + scoped half guessed. Measured on a live server booted at + `metadata: { prefix: '/metadata' }`, all six went to + `/api/v1/environments//meta`, which that deployment answers 404. + + The six now build through `metaUrl()`, which takes its base from `_apiBase()` + and its prefix from the new `_metaPrefix()` — the exact sibling of the + `_dataPrefix()` derivation that fixed `crud.dataPrefix`, fallback discipline + included. `_metaPrefix()` prefers the advertised `routes.metadata`, recovers the + prefix from `routes.data` as a second equation over the same `realBase` when the + advertised value is not the conventional one, and **declines to `/meta`** + whenever the document does not determine the answer: an SDK must not become + unusable because a server's discovery document is missing a key. + + Deployments on the default prefix are unaffected, by construction and by + measurement: the conventional-suffix rule is taken first, so a default + deployment is answered from `routes.metadata` alone, and a client that never + connected never reaches a rule at all. The pinned negative control asserts the + six request URLs of a default deployment byte for byte, for a connected client + and for an unconnected one, and that the unconnected client puts no discovery + request on the wire. + + The unscoped metadata methods are untouched. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index cdcd20c91a..e4c73ff43d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.4.0", + "version": "17.5.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 47ccef2900..f9dc08d5b6 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,137 @@ # @objectstack/cloud-connection +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [1a25f4a] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f3b0791e82..0d87d01bfa 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.4.0", + "version": "17.5.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 (ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index a27c6f133b..f2511e65ad 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/connector-mcp +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 8e7ce78ceb..9caf0ad15f 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.4.0", + "version": "17.5.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 de7a065683..5f22fbb1b7 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/connector-openapi +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index e1b630c683..7a330dab9c 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.4.0", + "version": "17.5.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 195702e5c3..a1b0f48c6b 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/connector-rest +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 8813fc217f..cd700b8a44 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.4.0", + "version": "17.5.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 192dbc70c7..e8aa794ff2 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/connector-slack +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 5eb1c7b7ae..a35beec817 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.4.0", + "version": "17.5.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 073d4fc2af..2b80d8f424 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8e4edce2a2..c64eb6b068 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.4.0", + "version": "17.5.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 807bb1869d..32fa98a057 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,420 @@ # @objectstack/core +## 17.5.0 + +### Minor Changes + +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + + +### Patch Changes + +- 4c42fd1: fix(core): an absent or empty path is no longer exempt from the ADR-0069 auth gate (#7898) + + `isAuthGateAllowlisted` answered `true` for a falsy path — it treated "no path" + as allow-listed. That is a fail-OPEN default on an authorization seam: any + caller that reached the ADR-0069 gate with an absent or empty `path` was exempt + on **every** route, and a transport author who simply forgot to populate `path` + disabled the gate with no diagnostic of any kind. + + ``` + FROM isAuthGateAllowlisted(undefined) -> true // exempt, on every route + isAuthGateAllowlisted('') -> true + + TO isAuthGateAllowlisted(undefined) -> false // exemption must be earned + isAuthGateAllowlisted('') -> false + ``` + + Exemption is now something a path has to EARN by naming an allow-listed route, + so the failure mode of omission is a `403` rather than a bypass. The predicate + is split in two so it carries exactly one meaning: a private + `matchesAllowlistedRoute` answers the route question for a real, non-empty path + — its body is unchanged, the #16839 anchoring rules included — and the exported + predicate answers "is this request exempt", which a request with no path is not. + + **No current caller's behaviour moves.** The caller census was re-run: the same + four production call sites, and no fifth. Two of them (`RestServer.enforceAuth`, + `shouldDenyAnonymous`) already guard for a non-empty path and so only ever reach + the predicate with a real string; a corpus differential against the pre-flip + predicate over more than 10,000 paths moves exactly one input — the empty string + — and nothing else, in either direction. + + **The one exemption that remains for a genuinely pathless caller is explicit**, + and lives at the one seam that really routes by body: `shouldDenyAnonymous` + declares `path` optional and decides the no-path case itself (it denies), ahead + of this predicate. That guard is deliberately kept rather than collapsed into + the now-agreeing default — a seam's contract should not be re-derived from what + a predicate happens to do with a falsy argument. + + **Known follow-up, tracked as #17625.** The dispatcher's bare-root + `` `${prefix}/` `` arrives as `cleanPath === ''` (the trailing slash is + stripped), which was exempt via the fail-open default and is not exempt now, so + a *gated* session — one carrying an `authGate`, i.e. an expired password or a + required MFA enrollment — reaching the bare root gets a `403` instead of the + discovery payload. Every named remediation route (`/auth/*`, `/health`, + `/ready`, `/discovery`, `/me/apps`, `/me/localization`) is unaffected, so + remediation itself stays reachable. Normalising that empty `cleanPath` is step 2 + of the same ruling and is **not** a tolerance re-added here. +- cf79182: `isAuthGateAllowlisted` matches allow-listed routes at a mount boundary, so an object named `auth` or a record whose id is `health` no longer bypasses the ADR-0069 authentication-policy gate. + + The predicate that decides which paths are exempt from the password-expiry / enforced-MFA gate matched with two UNANCHORED tests: `path.includes('/auth/')` matched at any position, and an `endsWith` test over `['/health', '/ready', '/discovery', '/me/apps', '/me/localization']` matched at any depth. A path segment whose VALUE merely spelled one of those tokens therefore carried the exemption — and object names and record ids are tenant-controlled. Both transport seams hand the predicate a data-plane path directly (`HttpDispatcher.enforceAuthGate` passes `cleanPath`, `RestServer.enforceAuth` passes `req.path`), so these were reachable requests. Measured on the built package before the repair: `/data/auth/123`, `/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were all exempt, while `/auth/me` (exempt) and `/data/contacts/1` (gated) held as controls. + + - **What replaced them.** The path is read as segments and each test is anchored to a mount base — `/api/v1`, `/api`, or the empty base the dispatcher sees (the hono adapter hands `dispatch()` the app prefix already stripped) — plus at most one environment scope immediately after that base (`/environments/`, or ADR-0006's superseded `/projects/`), because the dispatcher evaluates the gate before its scoped-URL strip. `/auth/…` at that position stays exempt; the five bootstrap reads are EXACT routes there instead of suffixes. The scope is only recognised immediately after a base, which is why `/data/environments/x/health` is not a scoped `/health`. + - **This only ever removes exemptions.** Measured, not asserted: over a generated corpus of 111,152 paths, the number that are newly exempt is **0** and 25,979 stopped being exempt. The check is kept as a test, with the pre-anchoring predicate transcribed beside it, so a later widening cannot arrive quietly. + - **Every genuinely-exempt shape still is**, pinned in both directions: `/auth/sign-out`, `/health`, `/ready`, `/discovery` (dispatcher shapes); `/api/auth/sign-in`, `/api/v1/auth/change-password`, `/api/v1/auth/me/permissions`, `/api/v1/health`, `/api/v1/me/apps`, `/api/v1/me/localization`; and the scoped `/api/v1/environments//auth/sign-out`. + + **If you serve the API from a non-default mount,** an allow-listed route reached as `${basePath}/${version}/…` with `basePath`/`version` moved off `/api` and `v1` is no longer named by the allow-list. That price cannot be avoided: `/rest/v2/health` and `/data/xyz/health` are the same shape, so a rule that accepts an arbitrary base is the defect itself. It costs nothing at either live seam — the dispatcher's path arrives base-stripped, and REST registers its control-plane routes without `enforceAuth` at all — but if you gate a custom mount through this predicate, mount the remediation routes under one of the named bases. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index e08798270f..561b3281e8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.4.0", + "version": "17.5.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 ea590320fe..054905116d 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,49 @@ # create-objectstack +## 17.5.0 + +### Patch Changes + +- f6b7c53: fix(cli): re-measure the `better-auth` > `better-sqlite3` peer record, correct what it credits, and pin the declaration it justifies (#16813) + + A tree containing `@objectstack/cli` reports an unmet peer on every fresh + resolve — `better-auth` peers `better-sqlite3@^12.0.0`, the CLI declares + `^13.0.3` — and the reading that decides what to do about it lived only inside + the scaffold generator's prose. No range moves here and no resolution moves: + what changes is the recorded reason, which had two measured errors in it, plus + a gate that now holds the declaration to that reason. + + **The declaration is correct and stays at `^13`.** Three readings, taken rather + than inherited: + + - The peer is `optional`, and it governs exactly one configuration — a raw + better-sqlite3 `Database` passed to better-auth's `database` option. + `AuthManager.createDatabaseConfig()` returns an ObjectQL adapter factory, or + `undefined` for better-auth's in-memory adapter. Never a `Database`. + - better-auth cannot be incompatible with better-sqlite3 13, because it never + touches it: of the 464 files in the published `better-auth@1.7.2` tarball, + exactly one names better-sqlite3 — `package.json`, the peer declaration + itself — and no code file references it (positive control: `kysely` names 9). + It accepts a `Database` the caller constructs; its own sqlite test path uses + node's built-in `node:sqlite`. + - Pinning back to `^12` is not a neutral alternative. Measured on a bare + project depending on `@objectstack/cli@17.3.0`, it clears the report only by + resolving a **second** native better-sqlite3 (12.11.1 beside 13.0.3) that + nothing loads. The scaffold's existing `allowedVersions` entry clears the + same report with the lockfile byte-identical. + + **Two corrections to the record.** It credited `@objectstack/driver-sql` for + the 13.x copy; on the chain that actually reports + (`cli` → `runtime` → `plugin-auth` → `better-auth`) the binding copy is the + CLI's own `optionalDependencies` entry, which pnpm names in the warning itself. + And it was measured on better-auth 1.7.1 while the family has been pinned at + 1.7.2 since — re-measured, with the empirical reading replaced by a structural + one. + + The scaffold's rendered `pnpm-workspace.yaml` comment changes wording in both + producers (`objectstack init` and the `create-objectstack` blank template); the + declarations, the widening entry and the resolution are untouched. + ## 17.4.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 80f367d026..32ee03a6b5 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.4.0", + "version": "17.5.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 6c40a06b5e..a6c9823632 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,375 @@ # @objectstack/driver-memory +## 17.5.0 + +### Minor Changes + +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- 555a89c: fix(driver-memory): refuse a call the engine tenant-scoped, instead of silently answering with every organization's rows (#16589) + + **BREAKING** for a `driver-memory` deployment that holds more than one organization's rows: an operation the engine tenant-scoped now refuses loudly instead of answering. Shipped as `minor` under the launch-window convention, the same grading the driver's `update()`/`upsert()` type-surface narrowing used. + + Two predicates decided "is this object tenant-scoped", and they disagreed on the default case. The engine scopes an object **unless** it opts out (`buildDriverOptions`: `execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated`), while this driver's boot guard refused only an explicit opt-**in** (`declaresTenantScope`: `tenancy.enabled === true`). An object that **omits the `tenancy` block entirely** — the common case — therefore fell between them: the engine scoped it, the guard never saw it, the deployment posture really was `single` so the posture check passed, and the driver then discarded the scope and returned every organization's rows. A SQL driver refuses the same read. + + This driver still implements **no row-level tenant isolation**, and deliberately does not gain any: it declines to answer rather than answering correctly. `assertCallNotTenantScoped` is a third seam beside the two boot seams, and it judges the scope the engine actually handed over (`DriverOptions.tenantId` / `tenantIds`) rather than re-deriving the engine's predicate from object metadata — a driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure. It runs first in every driver door that accepts a `DriverOptions`, so a refusal leaves the store exactly as it found it. + + **⚠️ Every isolation measurement previously taken on the memory driver is void and must be re-taken.** A suite asserting "tenant A cannot see tenant B's rows" passed here trivially — not because isolation worked, but because both tenants' rows came back to every caller and the assertion was written against a single tenant's fixture. An app that proved out its isolation model on this driver measured nothing. + + What is unaffected, and why: an object declaring `tenancy: { enabled: false }` is never scoped by the engine (ADR-0066), so the driver never sees a scope for it and serves it unchanged; a caller with no organization context is never scoped either, which is the ordinary dev, example-app and single-organization path. Only a call that actually arrives carrying a tenant scope is refused. A deployment that needs organization-scoped reads in development uses `@objectstack/driver-sql`, whose `:memory:` connection is the closest in-process replacement; a deployment whose data genuinely is platform-global can say so with the ADR-0066 posture, which stops the engine scoping it at all. + + The refusal reuses the existing `MemoryMultiTenantUnsupportedError` and its `MEMORY_MULTI_TENANT_UNSUPPORTED` code rather than introducing a second error family: the cause is identical, so a host that already recognises the boot refusal recognises this one with no new code and no second code to learn. + + Also corrects `declaresTenantScope`'s docstring, which closed on a false sentence — "every object in a single-tenant deployment omits the block". A `single` posture constrains the **wall**, not the number of organizations: a `single`-posture run was measured holding 13 `sys_organization` rows, with each row carrying whichever `organization_id` it was written with. The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test. + + +- b90aff8: fix(driver-memory): a scalar comparand against a stored ARRAY is read as membership on both filter faces, so a filter written to narrow stops returning rows it never selected (#16838) + + `memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==` converts a stored ARRAY to a primitive — `['a','b']` becomes the string `"a,b"` — so this package's reference matcher and its live query path (`InMemoryDriver.find`, through mingo) answered the same filter two different ways, in both directions at once: + + | filter | stored value | reference matcher, before | live query path | + |---|---|---|---| + | `{ tags: 'a' }` | `['a','b']` | no row | the row | + | `{ tags: 'a,b' }` | `['a','b']` | the row | no row | + | `{ tags: 'a' }` | `['a']` | the row | the row | + + The second row is the sharper one: a **false positive**, a filter written to narrow returning a row it should not, which on a read scope is a permission concern rather than a degraded filter. The first is fail-open in the other direction and just as silent — `if (!rows.length)` cannot tell "genuinely none" from "the predicate asked the wrong question". + + **What changes.** A stored array is now read as its elements, and each is asked the question the arm asks of a scalar: the answer for a row storing an array is the OR of the answers for the rows storing its elements. That is MongoDB's array semantics and therefore mingo's, so the reference face converges on the path this package's users actually run rather than on a third reading nobody wrote. One level only — a nested array is not descended into, matching mingo. `$eq` and `$ne` take the same equality as the implicit spelling, so `$ne` stays the exact complement. + + **What does not change.** An array in the **comparand** position is still refused (`INVALID_FILTER` / 400) by the shape gate every face of this package runs; this is the VALUE side, which that door does not judge. The live query path is untouched — it already answered membership — so a caller who only ever used `find()` sees no difference. Callers who compared results against the reference matcher, or who ran it directly as a driver double, will see a stored array select on membership instead of on its joined string. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 0ce5cec066..2bb55234d0 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.4.0", + "version": "17.5.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 329093f028..f82d8545b4 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,82 @@ # @objectstack/driver-mongodb +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 5fb6a0b0d4..26c955fdde 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.4.0", + "version": "17.5.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 da91d9f31c..b71e00c4c1 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,509 @@ # @objectstack/driver-sql +## 17.5.0 + +### Minor Changes + +- 3cbcedb: feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record | null` — it has always answered `results[0] || null`), `create()` (`Record`), `bulkCreate()` (`Record[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder. + + Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes. + + `@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry. + + Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations. + + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- 77c801e: feat(driver-sql)!: the file family's physical column holds the bare `sys_file` id, per deployment (#15989) + + + + **BREAKING** on the published storage behaviour of `@objectstack/driver-sql`, under the maintainer ruling on #15041 (decision batch #49 item 1), verbatim: 「15041 应该改为实际 id 保存。选A,其他同意」. The physical column for the file family — `file` / `image` / `avatar` / `video` / `audio` — holds the **actual `sys_file` id**, a bare id string in a string column, rather than a JSON-quoted id in a JSON column. The SQL generator already emitted `VARCHAR(2048)` for the family and does not move; the driver is the side that moves. + + Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + **The switch is per DEPLOYMENT, and its default is today's encoding.** The ADR-0104 addendum forbids keying it on the `adr-0104-file-references` flag alone: every creation-attested store since 17.0 and every deployment that ran `os migrate files-to-references --apply` before a column step existed holds that flag *and* JSON-quoted ids in a JSON column. The evidence is `sys_migration.columns_moved_at`, which reaches this driver as the new published option `SqlDriverConfig.fileColumnsMoved` — a boolean or an async resolver, resolved once at `initObjects` and memoized. **Every way of not knowing answers "not moved"**: the option omitted, a resolver that throws, a resolver that never runs, a host that never calls `initObjects`. Absence is the JSON arm because every flag row that exists in the world today lacks the field, and a driver that guessed the other way would write bare ids into a JSON column. + + **What an UNMOVED deployment gets** — which is every deployment until something supplies that option — is today's driver, with exactly one answer changed: + + - the column is still `json` / `jsonb` / SQLite `TEXT`, the write still JSON-encodes, and `isJsonField` still answers `true` for the family; + - a media cell whose bytes are a JSON-quoted id **sitting in a character column** now reads back as the id instead of as the id with its quotes. That population is not hypothetical: a database built by `os generate migration --format sql` has a `VARCHAR(2048)` media column, and MEASURED on live PostgreSQL 16.13, the driver wrote `"file_01HXYZ"` into it and handed it back verbatim — every consumer that matches the raw stored form (file resolution, ownership claims) refused it. SQLite never had this defect: its read arm parses the cell and keeps the raw string when the parse fails, which is why the gap was a server-dialect one. + + **What a MOVED deployment gets**: the family leaves `JSON_COLUMN_TYPES`, so `isJsonField` / `formatInput` / `formatOutput` stop treating a single-value media field as JSON; `createColumn` builds `varchar(2048)` — the generator's own width, mirrored by `varcharColumnChars` so the drift detector reads the column the emitter actually builds; and the id on disk is the id. Throughout the window the read path accepts **both** encodings on every dialect, so a cell a column step has not converted still reads correctly. The decode is deliberately narrow — it engages only on a leading `"`, `{` or `[`, none of which can begin a `sys_file` id, a resolver URL or a `data:` URI — because an all-digit id would otherwise parse to a number. + + `multiple: true` media is unaffected on both arms: its value is a list of ids, it is a JSON column on every deployment, and `createColumn` decides `multiple` above its type switch. + + **The drift detector moves with the writer.** `JSON_COLUMN_FIELD_TYPES` no longer names the family, because the family is no longer a constant on either side; `diffManagedTable` takes a `fileColumnsMoved` input instead, and OMITTING it reproduces this module's previous verdicts exactly — an unthreaded caller keeps reporting a `varchar` media column as the corruption it still is on an unmoved deployment. Without this, a deployment that moved its columns would be told by its own tooling to convert them back to `json`, i.e. to undo the ruling. + + **Not shipped here, and named rather than implied:** the column step itself. `os migrate files-to-references --apply` does not yet retype or rewrite media columns, and nothing in this diff moves any deployment's storage. A deployment moves only when it runs that step and its host supplies the arm, and the two must be one act — MEASURED on SQLite: after the columns are converted, a driver still on the JSON arm reads the migrated column correctly but its next write re-quotes. +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- 82cb69f: A `multiple: true` boolean column keeps its `$contains` membership filter + + A `multiple: true` field is stored as a JSON TEXT array, and on such a column + `$contains` is not a substring test — it is the MEMBERSHIP spelling, the one + operator #7398 left working there after refusing the equality family. The + declared-type gate added in #14079 fired on the boolean limb regardless of + storage shape, so a membership filter over a `multiple: true` `boolean` or + `toggle` column compiled to the always-false constant: + + ``` + { flags: { $contains: 'true' } } + - select * from `probe_tbl` where 1 = 0 (matched nothing) + + select * from `probe_tbl` where `flags` GLOB '*true*' (matches the rows whose array holds it) + ``` + + That is the fail-CLOSED direction: the query returns a `200` with no rows, + byte-identical to a filter that legitimately matched nothing, so an author sees + "no matching records" and doubts their data rather than the filter. Both + registry fills — `initObjects` and `registerExternalObject` — were affected, and + both are fixed, because the repair is at the predicate they share. + + The same shape on a `multiple: true` NUMBER was already correct (its registry is + filled `!field.multiple`), and #15683 spelled the equivalent carve-out for the + temporal limb at the predicate. This change spells it on the boolean limb, the + one that had neither. `booleanFields` itself is deliberately unchanged: it is a + read-coercion registry, and the three other seams that read it — the Postgres + aggregate cast, the presentation-kind door and `formatOutput`'s row pass — are + about "this column holds a boolean", which a multi-valued column still does. + + ⚠️ Not a widening of the gate: a SCALAR `boolean` / `toggle` column still + answers the declared no-match for every positive text operator and `$notContains` + its exact complement, unchanged. What moves is exactly the JSON-column cell. +- d46deba: A `multiple: true` boolean/toggle column reads back as its stored array, not as a single inverted `true` + + `formatOutput` runs its `jsonFields` pass first, which `JSON.parse`s the cell + into a real array, and then its `booleanFields` pass did + `data[field] = Boolean(data[field])`. Every non-empty array is truthy, so a + `multiple: true` `boolean`/`toggle` column presented a single `true` whatever + the array held — a stored `[false]` read back as **`true`**, the opposite of + what is stored, with no error anywhere. `readPresentationKind` hands the same + presenter to the `aggregate()` / `distinct()` doors, so the collapse was not + confined to the row-read door. + + **Fixed at the registry fill.** `&& !field.multiple` is the condition the three + neighbouring pushes in both registration blocks already carry (`mediaCols`, + `numericCols`, `numericValueCols`); `booleanCols.push(name)` was the single + omission, in **both** fills (`registerExternalObject` and + `registerManagedObjectMetadata`). A `multiple: true` boolean/toggle is a JSON + column here, and its array is written faithfully — only the read collapsed it. + + **What moves for a caller.** A `find()` / `aggregate()` / `distinct()` read of a + `multiple: true` `boolean` or `toggle` column now returns the stored array of JS + booleans (`[false]`, `[true, false]`) where it previously returned `true`. Code + that consumed the old scalar was reading a value that did not reflect storage — + including for an all-`false` array. Scalar `boolean`/`toggle` columns are + unchanged and keep their stored-`1`/`0` → JS `true`/`false` coercion; the + `multiple: true` number and `tags` classes were already correct and do not move. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is + `seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; + that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and + whether it should go is tracked by #17167. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index be45e44a66..7d86bd873f 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.4.0", + "version": "17.5.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 7c8d9f6be7..4a06796028 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,183 @@ # @objectstack/driver-sqlite-wasm +## 17.5.0 + +### Minor Changes + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index dbc85948e0..2174ef4de1 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.4.0", + "version": "17.5.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 845216d720..69a6c119f1 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,209 @@ # @objectstack/driver-turso +## 17.5.0 + +### Minor Changes + +- 3cbcedb: feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`. + + Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record | null`, `Record`, `Record[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes. + + `explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations. + + +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- bdea10a: fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609) + + ## What was wrong + + In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`). + + ## What changes + + - Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both. + - New tables get their indexes in the same batch as `CREATE TABLE`. + - **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten. + - An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync. + - Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table. + + ## Upgrading + + Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 571c9abb9c..5dee92c472 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.4.0", + "version": "17.5.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 ca6b37ce71..a8b32e50fb 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/formula +## 17.5.0 + +### Minor Changes + +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 66277a5621..cc111524e0 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.4.0", + "version": "17.5.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 569a60ec5e..e9456055c2 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,717 @@ # @objectstack/lint +## 17.5.0 + +### Minor Changes + +- 0fb6f97: fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328) + + + + **BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and + `validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A + TypeScript consumer that imported that constant (to suppress the rule, or to route it) + stops compiling on the import, and the compiler names the site — a more precise channel + than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged + and still valid. + + The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a + rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2 + / page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real + authored 3-column section at all three of the widths that sentence names (390 / 720 / + 1700), that misalignment does not happen. The renderer emits one container-query-scoped + span class clamped to the section's declared column count, so the cell starts at a real + column boundary at every width and rendered overflow is 0px in every configuration — + including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp + did not work. The clamp is precisely why the claim was false, and the rule's own file + already recorded the clamp a few lines above the claim. + + The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to + the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule + warned about one spelling and recommended the other, and they are the same thing. At the + modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an + author who complied was left worse off than one who ignored it. + + With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so + the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a + `colSpan` within the column count renders exactly as authored, and one above it clamps. + Every test that pinned the rule's wording or its firing set was re-judged in place with + the reason recorded, never deleted, and each re-judged pin is paired with a live finding + on the same fixture so that a walk which stopped reaching the site could not pass as a + withdrawal. +- 86f4246: `approval-approvers-may-resolve-empty` now covers the `manager` rung, not just the group-routed ones. + + The rule exists for the empty-slate dead-end (#3424): an approver slate that resolves to nobody, with `lockRecord` turning that into a stranded record. It reasoned about `position` / `team` / `department` and said nothing about `{ type: 'manager' }` — which has the same failure shape and a strictly worse cause. A `position` rung resolves empty because the position is unstaffed, and an operator can staff it. A `manager` rung resolves empty because `sys_user.manager_id` is unset, and an operator **cannot** set it: the managed-update whitelist for `sys_user` is exactly `{name, image, locale}` (ADR-0092), the auth admin endpoints do not accept the column, and the Console renders no field for it. So the rule warned about the rung an author can rescue and stayed silent on the one they cannot — and `manager` is the canonical first rung of a tiered approval ladder, so the silent case was also the common one. + + - **What fires.** A node whose approver slate is made up ENTIRELY of `{ type: 'manager' }` rungs now draws one `approval-approvers-may-resolve-empty` finding, at the same `info` tier as its `position` sibling. `manager` resolves through `sys_user.manager_id` of the record's owner and yields nobody when that column is unset; when nothing else is on the node, the request waits forever, and under the default `lockRecord` the record stays locked. + - **What it does not claim.** The message states in as many words that this is a static check which cannot read the column, and that it does not assert the slate IS empty — it reports that nothing else on the node can approve if it is. A lint rule must not claim a runtime fact it did not read. + - **The remedy it prescribes, with the routes graded rather than listed.** An exact diagnosis whose prescription cannot be carried out is worse than no prescription, so the hint separates what this platform provides from what it does not. A **seed, or any other system-context write**, populates the column here — both write guards gate on `isUserContextWrite` (`userId && !isSystem`), so a system-context write bypasses the managed-update whitelist by construction. **SCIM provisioning and directory sync** are named too, because a deployment running a real one may well populate the column through it — but named as a path the deployment itself supplies: this repo declares the SCIM Enterprise `manager` attribute without projecting it onto the column, and the admin bulk import does not write it either (`SYS_USER_IMPORT_UPDATE_FIELDS` is `{name, image, locale}` plus `phone_number` and `role`, and `manager_id` is listed there among the admin-surface-only columns). Editing the user in the Console is explicitly ruled out, since it cannot write the column at all. And the escape that depends on none of this stays on offer: add a fallback approver that cannot resolve empty, such as `{ type: 'org_membership_level', value: 'owner' }`. + - **When it stays quiet — and on which surface.** A stack whose own seed data wires `sys_user.manager_id` on any seeded row has shown the linter that it populates the column, and the advisory is suppressed. Seed rows are the only manager-chain evidence a stack can carry, so that is the whole of what this check reads on the question. ⚠️ That suppression is **CLI-side only**. The runtime publish gate hands rules a `RuntimeStackContext` whose collections are fixed — `objects`, `permissions`, `books`, `datasets`, `pages`, and no `data` — so a Studio publish of a manager-only flow carries no seeds to read and draws the advisory however the tenant's users are wired. That is a surface asymmetry, not a broken suppressor: an `info` finding never blocks a publish, it rides the 2xx `advisories`. Noted here so a reader who seeds correctly and still sees it fire on publish does not go looking for a bug in the rule. + + Existing verdicts are unchanged. The new arm is scoped to slates that are entirely `manager` rungs, which keeps it disjoint from the group-routed arm by construction — no node can draw both findings — and leaves every `position` verdict exactly as it was, mixed slates included: a `[position, manager]` node stays silent, as it is pinned to. + + This is a purely additive widening of a published package's public surface — the rule begins covering a case it was silent on — so it is graded `minor`, the floor that act carries regardless of the commit type. + + No severity moved. The finding is `info`, so it lands in the advisory channel on every consumer: `os lint` renders it as a suggestion and its exit code is unchanged (a suggestion does not fail a run even under `--strict`), and the runtime publish gate returns it on the 2xx `advisories` array rather than refusing the write. What changes is the report, not any verdict. +- 31064ca: fix(lint): a field-typed rule reads the registry's own type for an injected column, so `created_at` / `updated_at` stop escaping the preset-comparand refusal (#16340) + + `@objectstack/lint`'s object graph recorded the registry-injected system columns by NAME only. A path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`, so every rule asking a SECOND question about the leaf — "is it temporal?" — had to treat it as unanswerable and stay silent. That silence landed on the two most-filtered columns in the platform. + + Measured on `origin/main` `d57611dfd3`, one dashboard widget over one object declaring `close_date: date` and authoring no `created_at`: + + | authored filter | before | after | + |:--|:--|:--| + | `close_date: 'last_30_days'` (authored `date`) | refused | refused | + | `created_at: { $gte: 'last_30_days' }` (ordering — arm 1) | refused | refused | + | `created_at: 'last_30_days'` | **silent** | refused | + | `created_at: { $eq: 'last_30_days' }` | **silent** | refused | + | `updated_at: { $in: ['last_30_days'] }` | **silent** | refused | + | `stage: 'this_quarter'` (a `select` column) | silent | silent | + + The engine already refused all three of those at query time (`INVALID_FILTER` / 400, the registry's field map in hand), so the gap was purely author-time: `objectstack lint` and the runtime publish gate passed a filter the runtime then refused with a 400 on first render — and an AI author's correction loop only sees what fails the build. + + ## What changed + + `GraphObject.injected` is now a `ReadonlyMap` rather than a `ReadonlySet`: each injected column carries the registry's own definition. Both halves are DERIVED from one plan — membership from `resolveInjectedSystemColumns`, the slice from `injectedSystemColumnDefs` (`@objectstack/spec/data`, the same tables `applySystemFields` spreads at registration) — so lint never hand-copies "`created_at` is a datetime" and cannot drift from the runtime that provisions it. `resolveFieldPath` populates `meta` for an injected leaf accordingly, and `filter-preset-comparand`'s field-type oracle lost its `verdict.injected` bail: the marker says WHO wrote the column, and the ruling turns on what the column IS. + + `id` is the one addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty and a second question about it is still unanswered, truthfully and only there. The `select`-column reading arm 2 exists to protect is untouched: no injected column is a picklist. + + **Behaviour change for authors**: a stack that filtered an injected `date` / `datetime` column against one of the thirteen dashboard date-range preset names in an equality or membership position now fails `objectstack lint` and the runtime publish gate where it previously passed. Every such filter was already refused by the engine at query time; the error simply moves to where the filter is written. Write the `{date-macro}` window the message names, or an ISO date. + + **Type change for direct consumers of the seam**: `GraphObject.injected` changed from `ReadonlySet` to `ReadonlyMap`. `.has(name)` answers exactly as before; code that iterated the set or spread it into one needs `.keys()`. Shipped as `minor` under the repo's launch-window convention. + + ## Two more rules inherit it, in the same edit + + The type reaches every rule that asks a second question about a resolved leaf, which is the whole reason it was fixed at the seam rather than inside `filter-preset-comparand`: + + - **`list-view-field-dotted`** now refuses a dotted list-view filter key whose head is an injected column, on the same axis as an authored one. `created_at.x` reads as the `datetime` scalar it is (nothing beneath it for a path to reach) and `owner_id.name` as the `lookup` it is (it stores an id, not an embedded document). `assertFilterIsMaterializable` and the REST ingress have always answered `400 INVALID_FIELD` for both — the linter was silent only because the type was missing here. + - **`dataset-include-unknown`** now judges an `include[]` entry naming an injected column instead of bailing on the marker: `include: ['owner_id']` joins (it is the registry's `lookup`), `include: ['created_at']` is refused (a `datetime` derives no join, so every dimension written against that prefix addresses nothing). + + `id` falls through the untyped branch of all three rules — the DRIVER provisions the primary key and no definition table describes it, so an unreadable head is what the door sees too, and none of them invents a refusal there. + + A relationship HOP through an injected column stays a skip (`unknowable` / `injected-hop`), deliberately: the slice now carries `reference`, and traversing it would newly judge every path through a platform anchor wherever `sys_user` is compiled into the stack — a widening with its own findings to measure. +- f89dd33: `object-reference-unknown` now judges a field's `reference` — the target of `Field.lookup()` / `Field.masterDetail()` / `Field.user()` — with the same four-rung ladder it applies to every other object-name site, and `os build`'s per-package run resolves those names across the artifact's `packages[]` + + `FieldSchema.reference` is `z.string()`: the schema holds it present and non-empty on `lookup` / `master_detail`, and nothing anywhere asked whether the name resolved. So `os validate`, `os lint` and `os build` all exited 0 — no diagnostic of any severity — on `Field.lookup('zzz_object_that_does_not_exist')` (measured on 17.3.0), and the miss surfaced only at runtime: the record picker asking the REST layer for an object that is not registered (404 `OBJECT_NOT_FOUND`), `$expand` failing on the field, the form rendering a control that can never resolve a value. + + The site joins `validateObjectReferences` and rides its existing ladder, so the three commands judge it identically: + + 1. resolves in the stack's own objects, or in the objects an entry of this artifact's `packages[]` provides → ok; + 2. resolves in `PLATFORM_PROVIDED_OBJECT_NAMES` (`sys_user`, the target `Field.user()` writes) → ok; + 3. unresolved and not platform-prefixed → **`error`** — `os validate` / `os build` / `os lint` exit 1; + 4. unresolved, platform-prefixed, registered by nothing (`sys_approval_process`) → the existing `object-reference-unregistered-platform` advisory. + + Judged: `lookup`, `master_detail`, `user`. Not judged, on purpose: `tree` (the object schema already refuses any target but the own name), a `reference` on a non-relationship type (inert), and `objectExtensions[].fields` (an extension targets an object another package owns, routinely one this artifact does not carry). + + ## Migration + + **A build that used to pass can now fail.** Rung 3 is a new `error`-level refusal on a published accept set. Point the field at one of the stack's own objects, at an object another package of the same artifact ships, or at a platform object by its full name (`sys_user`, not `user`); the finding names the objects that resolve and suggests the nearest one. + + **A reference into a sibling package of the same release artifact resolves — it needs no annotation.** ADR-0130 makes the release artifact the co-ownership boundary, so `os build`'s per-package leg now hands each package's stack the artifact's `packages[]` as resolution context (`compile.ts`). A module's `crm_order.account` → its App package's `crm_account` is an ordinary rung-1 resolution on all three commands. This changes what a rule can resolve, never what it judges: the collections judged per package are still that package's own, and a name no entry of `packages[]` provides still errors on the per-package run exactly as it does on the union one. + + **A reference into another RELEASE ARTIFACT still has no rung** — an app naming an object a separate product ships (HotCLM's `clm_contract.crm_contract` → HotCRM). It is unresolved and unprefixed, so rung 3 refuses it. The declared escape for that case resolves against declared manifest dependencies and is its own change; ⛔ it is deliberately not an authored per-field marker, which would be a one-line switch that silences the gate. +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- e4fd55d: Two new gating rules — `rls-predicate-unknown-field` and `rls-predicate-unknown-user-variable`: an RLS predicate that lowers correctly but names a column the object does not declare, or a `current_user.*` value nothing pre-resolves, is now an authoring-time `error`. + + The three shipped `rls-predicate-*` rules judge a predicate's **shape** — does it parse, does it lower, does it fit the platform's CEL bounds. Nothing judged what it **points at**. Measured as four injections at one site, in one run: `billing_address.country == "US"` reported `rls-predicate-unenforceable` and `is_private == = false` reported `rls-predicate-unparseable`, while `is_private_nope == false || owner_id == current_user.id` and `is_private == false || owner_id == current_user.nope` reported **nothing at all** — from the same site the linter had just reported twice. + + Both silent shapes are expensive rather than cosmetic, and they do **not** fail in the same direction — which is the part the card's own measurement did not reach. + + An unresolved `current_user.*` is refused by the pushdown compiler in **every** position, including under `!` and in a trailing `||` arm, so that half always fails **closed**: `RLSCompiler` drops the policy, the layer falls back to the `RLS_DENY_FILTER` sentinel, and the object disappears for every holder of the permission set — not because they were denied but because the narrowing they were granted resolves to nothing. + + An unknown **field** takes its direction from **position**, and one of the two is fail-**open**. `SecurityPlugin`'s field-existence safety net recognises only a *leading* `field ==` / `=` / `in` (`extractTargetField` is that shape match), so a miss there drops the policy and arms the deny sentinel — zero rows. A miss the net does not recognise — a negation (`nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])`) or any arm after the first — leaves the policy **kept**, and the phantom column lowers to a negated constraint that a row without that column *satisfies* (`noValueSatisfiesNegation`: `$ne` / `$nin` / `$notContains`). The authored narrowing is then **defeated rather than enforced**: measured at 3 of 3 rows, against 1 of 3 for the real narrowing and 0 of 3 for the same phantom column in a positive position, on the read path and on the write path's `matchesFilterCondition` alike. + + ⛔ That is **not** a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. Measured on driver-memory; driver-mongodb follows the same shared ruling; **driver-sql is NOT MEASURED** and is expected to fail closed by raising `no such column`. The runtime repair is tracked separately as #17042 and is deliberately not attempted here — these rules report the miss, in both directions, and the diagnostic says which direction applies so an author is not told "this denies everything" about a predicate that in fact matches everything. + + - **Two rules beside the three, not a widening of them.** The existing ids say *unenforceable* / *unparseable* / *over-budget* and are correct inside that scope; they are untouched, and the two controls above still report under them and under neither new id. The prescriptions differ (rewrite the predicate / fix the column name / pre-resolve the variable), and an author who suppresses one must not thereby suppress the other. The guards are disjoint by construction: the reference pass runs only where `isSupportedRlsExpression` has already said yes. + - **Where the existence answer comes from.** Field paths are read off the pushdown compiler's **own output** — the lowered `FilterCondition`'s keys are the columns the driver will be handed — and resolved through `object-graph.ts`, the shared index every field-existence rule in this package already uses. No new input path, no second parse of the predicate. The rule therefore inherits that module's three skips, each the difference between a finding and a false one: an object this stack does not define, an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at`, which are real at runtime and appear in no authored `fields`. + - **The `current_user` set is derived, not transcribed.** It is `RESERVED_RLS_MEMBERSHIP_KEYS` from `@objectstack/spec/contracts` — the keys an `IRlsMembershipResolver` may never supply *because the kernel already owns them*. A key added there stops being reported the same day, with no edit in this package. + - **§7.3.1 membership keys are left alone, and that boundary is the reason this rule can exist.** An app stages arbitrary sets into `ExecutionContext.rlsMembership` and references them as `field in current_user.`; the spec documents the pattern and `rls-predicate-unparseable`'s own hint recommends it. In an `in` position an unknown key is indistinguishable from a correct one and is never reported. It is decidable in the other positions only because the merge is array-only — the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request — so `owner_id == current_user.nope` is refused while `assigned_to_id in current_user.team_member_ids` stays silent. A key used in both positions takes the membership answer. + + **What moves for consumers.** A stack whose RLS predicate names a renamed column or an un-pre-resolved context value built clean before and now fails `os validate` / `os lint` / `os compile`. That is the point — the policy had already stopped doing what it was written to do, denying the whole object in one position and granting every row in the other. + + A stack whose predicates all resolve is byte-identically clean. The reading is the shipped showcase: 3 RLS clauses, all 3 judgeable against declared objects, **zero** findings — with three firing controls at the real site (an injected dangling column, an injected unknown variable, and an injected fail-open negation shape each produce exactly one finding) and two nonsense controls (an injected membership test against an unknown key, and a real-field/real-variable predicate, stay silent). `plugin-security`'s seed sets and hotcrm's built-permissions fixture also emit zero, but ⛔ **those two are not readings**: every policy target in the seeds is an object that package does not declare, and the hotcrm fixture carries no `objects` key at all, so all 71 and all 4 clauses respectively are skipped by construction. Declaring one of their objects makes the fixture report 2 — which is what a control is for. +- ba17017: `os lint` now refuses a `min`/`max` roll-up whose answer cannot be stored in the column it rolls up into — `rollup/non-numeric-aggregand`, at `error`. + + `FieldSchema.summaryOperations` admits `min`/`max` over ANY child field, and the engine's `aggregateSummaryValue` returns the driver's answer verbatim (only an empty-set fallback stands between the backend and the stored value). A `summary` field is a member of the spec's `NUMERIC_VALUE_TYPES`, so `valueSchemaFor` answers `z.number().finite()` for it and `driver-sql`'s `createColumn` emits a float column. An ordinary "latest shipment" roll-up — `max` over a `datetime` child field — therefore computes an instant into a column the value contract says holds a finite number, and nothing between author and driver correlated the two. It is refused at authoring time rather than tolerated in a consumer (Prime Directive #12). + + - **The accept set** is the numeric class union the boolean class, read from `NUMERIC_VALUE_TYPES` and `BOOLEAN_VALUE_TYPES` rather than typed out. The first is the set that DEFINES the criterion — it is the membership `valueSchemaFor` consults to answer `z.number().finite()`, so a type joining it moves the value contract and this door together. The second is admitted on the authority of the `min(flag)=0` / `max(flag)=1` ruling pinned by the spec's own `AGGREGATION_CASES` (#11152): the answer is a number, so it fits. + - **It is NOT `isAggregateCompatibleWithFieldType`.** That table deliberately accepts `min`/`max` over the temporal class, because there the answer is returned to a caller and "return[s] a value of the field's OWN type" (#15768). Reusing it here would accept the very declaration this rule exists to refuse. The two questions look alike and are not — "can every backend give one answer" versus "does that answer fit the column this roll-up is stored into" — so this predicate is that table's `min`/`max` row narrowed by exactly the temporal class, and a test pins the disagreement. + - **Scope.** `min`/`max` only. `count` reads no value off the field; `sum`/`avg` over a non-numeric child is a different shape, whose accept set the aggregate table's own rows already exclude, and is not widened into here. + - **Silent where it cannot resolve.** An unknown child object, a field the child does not declare, or a field with no declared type produce no finding — the aggregate table's own consumer tier ("a consumer that cannot resolve a field's type must NOT call the predicate with a guess"). A partially-loaded model cannot draw a false refusal. + + No export moves: the rule id is an inline literal inside the already-exported `lintDataModel`, beside `rollup/missing-summary`. Measured across this repository, no declaration trips the new refusal — all three `min`/`max` roll-ups aggregate a `number` child field — so this adds a door rather than migrating anything. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- 131851f: New gating rule `security-fls-unknown-field`: an object-qualified field-permission key naming a field the object does not declare is now an authoring-time `error`. + + `security-fls-unqualified-key` has always caught the *bare* spelling — `fields: { budget: … }` — because the runtime evaluator matches FLS keys by their `.` prefix and a bare key matches nothing. The qualified-but-dangling spelling (`fields: { 'crm_account.description_nope': { readable: false } }`) has the identical runtime consequence and was reported by nothing: `PermissionEvaluator.getFieldPermissions` strips the prefix and looks the remainder up as a column, so a remainder no column answers to contributes nothing to the merged permission map. The masking the author declared **never enforces**, and the field stays as readable and as editable as the object-level grant leaves it — for every holder of the set. + + The failure direction is **fail open**, and this spelling is the one that accumulates: unlike a bare key it looks correct in review, survives rename refactors invisibly, and is exactly what a field rename leaves behind. + + - **A second rule, not a widening of the first.** `security-fls-unqualified-key` is correct inside its declared scope and is untouched; the two defects have different prescriptions (add the object prefix / fix the field name) and suppressing one must not suppress the other. Two ids, two messages. + - **Where the existence answer comes from.** The rule resolves through `object-graph.ts`, the shared index every field-existence rule in this package already uses — no new input path. It therefore inherits that module's three skips, each of which is the difference between a finding and a false one: an object this stack does not define (it may be another installed package's), an object with no readable field map (an ADR-0015 `external` object, an introspected datasource), and registry-injected system columns such as `created_at` or `owner_id`, which are real at runtime and appear in no authored `fields`. + - **A truncated key is the same defect and is reported by the same rule.** `fields: { 'crm_account.': … }` passes the runtime's prefix test and resolves to the empty column name, so it matches nothing exactly as a dangling name does. `PermissionSetSchema.fields` is `z.record(z.string(), FieldPermissionSchema)` — a bare string key with no pattern and no refinement — and this rule is the only reader of those keys, so before this change nothing reported it at all. A key naming an object this stack does not declare still falls to skip 1, truncated or not. + - **It mirrors the evaluator, including on a multi-dot key.** Only the first dot separates object from field, because `ObjectSchema.name` is `/^[a-z_][a-z0-9_]*$/` and cannot contain one. `'crm_account.owner.name'` therefore asks for a column literally named `owner.name` and is reported: FLS keys address columns, never joins, and resolving that as a relationship hop would have been a fail-open divergence from the gate the rule mirrors. + + **What moves for consumers.** A stack carrying a dangling FLS key built clean before and now fails `os validate` / `os compile`, and is refused at the runtime publish door for `permission` and `object` writes (this rule joins the existing `validateSecurityPosture` registration; no new registry entry). That is the point — the key was never enforcing anything. A stack whose FLS keys all resolve is byte-identically clean: measured on the shipped showcase, whose six authored keys emit zero findings, with a firing control (one injected dangling key produces exactly one finding) beside the zero. +- 5505646: fix(formula): the strict declaredness env declares `SCOPE_ROOTS` as `dyn`, so a bare reference behind a root name is no longer masked (#16412) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on published + CHECKERS, in the same sense as a route that starts refusing a request it should + always have refused — landing in the launch window as `minor` on both packages (during the window the bump level is + not the carrier of breaking-ness; this paragraph and the disposition above + are). Nothing that was already reported stops being reported, and no source + that is correct starts being reported. + + `firstUndeclaredReference` asks cel-js's checker for the first undeclared + identifier in a source. That checker returns exactly ONE error, and the helper + acts only on `Unknown variable: X`, so whenever the first error is of another + class every undeclared reference behind it in the same source went unjudged and + the helper answered `null` — which is also the value that means "every + reference is rooted". Four published call sites read that answer, and none of + them can tell the two readings apart. + + The widest way to reach that state was a disagreement between two environments + in this package about the same names. The strict env declared every + `SCOPE_ROOTS` member (`data`, `config`, `record`, `result`, `item`, `event`, + `input`, `user`, …) as `map`, while the permissive env that `celEngine.compile` + type-checks in leaves them `dyn`. `map` has no `==`, `<` or `+` overload, so an + ordinary comparison on one of those names compiled clean and then faulted `no + such overload` in the strict env only — taking the single error slot and + silencing everything behind it. An author reaches it by naming an object field + or a flow variable after a namespace root and reading it bare, which on a + metadata-editing form is not even a coincidence: that layer binds the row under + edit as `data`. + + The strict env now declares those roots `dyn`, which is what the list's own + doc-comment already claimed it was for — member access, arithmetic and + comparison on a root all deferring to runtime — and which `map` delivered only + the first of. The two environments agree about these names, so the class cannot + arise rather than being compensated for downstream. + + What starts reporting, measured on each published surface: + + - `@objectstack/formula` `validateExpression` with `scope: 'record'` — a bare + reference behind a root name is the hard error it always was for the same + identifier written first (`ok` was `true` with zero errors; it is now `false`). + - `@objectstack/formula` `validateExpression` with `scope: 'flattened'` — the + did-you-mean warning reaches a misspelled field behind a root name. + - `@objectstack/lint` `visibility-bare-identifier` — a bare identifier behind a + root name in a `visibleWhen` predicate is a finding. Per that rule's own + message the console otherwise falls open and the element renders + unconditionally. + - `@objectstack/lint` flow-variable shadowing — a shadowed field read behind a + root name is warned. That rule's documented blind spot is now name-local, as + its wording always claimed: the colliding name itself is still not reported. + + ⚠️ One published answer also WIDENS, and it is not a reporting surface. + `inferExpressionType` (`@objectstack/formula`, re-exported from the package + root; read by `@objectstack/mcp` as `validate_expression.inferredType`) infers a + formula's coarse value type through `inferCelType`, which shares this same + strict environment. While the roots were `map` there was no `==`, `<` or `+` + overload for them, so an expression using a namespace root as a DIRECT OPERAND + did not type-check at all and the answer was `'unknown'`. With the roots `dyn` + those expressions type-check and the answer is the truthful CEL type: + `result + 1` and `record ? 1 : 2` → `'number'`, `record == "x"` → `'boolean'`, + `data == "x" ? "a" : "b"` → `'text'`, uniformly for every name on the list. No + answer changes from one concrete type to another and nothing narrows to + `'unknown'` — `size(record)` and `"a" in record` still answer, and a root that + is only the base of a member access (`record.amount > 100`) never consulted this + declaration. A consumer that keys off a concrete type therefore sees strictly + more expressions classified, never a different classification; for the + motivating consumer that means a formula written as `data == "x" ? "a" : "b"` is + now correctly seen as text rather than as unprovable. Pinned on both sides in + `validate.test.ts`. + + ⛔ Two first-error classes are NOT closed by this, and both stay pinned. A CEL + TYPE name (`type`, `string`, `int`, …) is declared by CEL itself, so no + declaration this package makes can reach it; measured on the strict env, the + message for `type == 'grid'` is byte-identical under a `map` and a `dyn` root + declaration. And `has()` handed a non-select argument still faults its own + class, which `@objectstack/lint`'s visibility rule masks at its own call site + (#16118) and which nothing else masks. + + The narrowing this helper is built on is unchanged: it still acts only on + `Unknown variable`, so `type(record.x) == string`, comprehension macros, guard + idioms, optional chaining and stdlib calls report nothing, and a widening of + that regex onto the overload message remains refused. +- 4ecfd2b: fix(lint)!: `objectstack validate` refuses a blank structural `condition`, the rule `registerFlow` has carried since #17322 (#17495) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): + `validateStackExpressions` — the pass behind `objectstack validate` — now + reports an `error` for a structural `condition` whose source is blank after + trimming. It reported nothing at all before. + + The value was already refused by two of the three doors. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`; #17322 rebound `AutomationEngine.registerFlow` to + that same rule, so the same value on a node's `config.condition` stops the flow + registering. `objectstack validate` was the door that still said nothing — so an + author got a clean bill, deployed, and the flow never registered: each boot path + in `service-automation`'s plugin wraps `registerFlow` in `try`/`catch`, logs one + `warn` naming the flow, and continues. On a `start` node that key is the + **trigger gate**, so the whole flow is armed by nothing. + + FROM → TO, for a build that used to pass and now fails: + + ```yaml + # FROM — validate said nothing; registerFlow refuses it at boot + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } + + # TO — either write the predicate you meant… + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: 'record.active == true' } } + - { id: branch, type: decision, config: { condition: { dialect: cel, source: 'record.rating >= 4' } } } + + # …or drop the key. An ABSENT condition is still not a malformed one: a start + # node with no `condition` is an ungated trigger, and that is unchanged. + ``` + + The refusal is the edge door's own sentence, not a second one — the finding + carries `EVALUATED_EXPRESSION_SOURCE_REQUIRED` verbatim, located at the node and + slot the author wrote (`flow 'f' · node 'gate' (start) condition`), because all + three doors now ask one imported schema. + + Unchanged, deliberately: the **evaluator**. A condition already stored blank + still answers `false` at run time — #15662's ruling on that half stands. What + moved is that it can no longer be authored past validate. + +### Patch Changes + +- e958468: fix(lint): a hook write-set finding on a handler-authored hook reports `path: hooks[i].handler` — a key the author actually wrote — instead of the lowered `hooks[i].body.source` (#16546) + + `hook-api-update-readonly-field` / `hook-api-update-readonly-when-field` + (`validate-readonly-hook-writes.ts`) and `hook-body-write-unknown-field` / + `hook-body-write-unprovisioned-anchor` / `hook-body-source-unparseable` + (`validate-hook-body-writes.ts`) all report their `path` against `hook.body`, + because that is the shape they parse. For a hook authored as an inline + `handler: async (ctx) => { … }` (39 of 39 hooks in the reference app), + `hooks[i].body` is not something the author wrote at all — `lowerCallables` + mints it from the handler before `os build` / `os lint` hand the stack to + these rules (#16095). The reported `path` therefore named a key that does not + exist in the author's own source file; grepping for `body.source` there finds + nothing. + + **What changed.** `lowerCallables` now records, per `lowerCallables()` call, + which `hooks[*].handler` ref strings got their `body` minted this way (as + opposed to a `body` the author wrote directly). The CLI's four lowering doors + (`os build`, `os lint`, `os validate`, `os init`/`dev`'s scaffold validation) + pass that set through `runAuthoringRules`'s `ctx.loweredHookRefs`, and the two + hook write-set rules use it to redirect a finding on a lowered hook to + `path: hooks[i].handler` — the key that replaced the function the author + wrote — with a message suffix ("judged on the metadata body lowered from the + inline handler") explaining why. A hook whose `body` the author wrote directly + is unaffected: `path` stays `hooks[i].body.source`, unchanged. + + **No verdict changed.** Which hooks are flagged, at what severity, and why is + untouched — #13653 and #4271 are unmoved by a word. Only the location a + finding points at, and the wording explaining it, are different. `os build` + and `os lint` continue to report the identical `path` and message for the + same hook (#16095's "one implementation, both commands agree" — now including + this). + + No `--json` field was added or removed: `path` and `message` keep their + existing shape (string), and this is a within-type value correction for the + one subclass whose old value could never be resolved against the author's + source in the first place. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [f55922f] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/sdui-parser@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 9f9fedda1f..d158d94fd8 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.4.0", + "version": "17.5.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 fdef8d61c4..fd1fc8bd1b 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,239 @@ # @objectstack/plugin-mcp-server +## 17.5.0 + +### Minor Changes + +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. + +### Patch Changes + +- f19dbcf: Connect an Agent is reachable from the Account app, so a non-admin can mint their own key + + `POST /api/v1/keys` mints a `sys_api_key` bound to the **caller**, and the + Connect-an-Agent page says the key "acts as you". But the page's only navigation + entry sat in the Setup app, which declares `requiredPermissions: + ['setup.access']` — so every non-admin following the shipped two-step guide, and + every reader of the runtime's own error text (`packages/mcp/src/plugin.ts`: + *"mint an API key (Setup → Connect an Agent, or POST /api/v1/keys)"*, and + `README.md`), stopped at step 1 while the endpoint behind the button had accepted + them all along. Measured before: a principal with no system permissions gets + `403 PERMISSION_DENIED` on `GET /api/v1/meta/apps/setup` and `nav_connect_agent` + is absent from the wire. + + `CONNECT_AGENT_UI_BUNDLE` now carries a **second** `navigationContributions` + entry, targeting the `account` app's `grp_account_developer` group beside the + `nav_account_api_keys` entry already shipping there. Measured after, over the + real composition (real `SETUP_APP` / `ACCOUNT_APP` / `SETUP_NAV_CONTRIBUTIONS`, + the real fold and the real RBAC-by-route filter): the same permissionless + principal gets `200` on `GET /api/v1/meta/apps/account` with + `grp_account_developer` carrying `['nav_account_api_keys', + 'nav_account_oauth_apps', 'nav_connect_agent']`, while `apps/setup` still + answers `403 PERMISSION_DENIED` with `connect_agent` absent from that body. + + **Nothing else moves.** No backend change, no authorization change, no change to + which permissions exist, and the published "acts as you" promise is unchanged — + it simply becomes keepable for the users it was written for. The Setup entry + stays exactly as it was, so admins keep the page where the guide points, and no + gate is added or removed anywhere: a navigation contribution registers exactly + when the page registers, so an opted-out deployment + (`OS_MCP_SERVER_ENABLED=false`) still gets no page and neither entry. + + ⛔ Ungating Setup was **not** the fix, and was measured rather than assumed: the + app-level `setup.access` gate fires before the group gate, so dropping the group + gate alone changes nothing, and dropping both serves 14+ unrelated Setup + surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to + every signed-in user. ⛔ Nor was a `requiresService: 'mcp'` gate on an + `account.app.ts` entry: the `mcp` service registers unconditionally in `init()` + while this bundle registers behind `isMcpServerEnabled()`, so such an entry + would outlive its page and 404 for every signed-in user on an opted-out + deployment. + + Both entries deliberately share the item id `nav_connect_agent` — one + destination, one identity. That is scoped, not a collision: `SchemaRegistry` + keys contributions by target app and `applyNavContributions(app)` consults only + that app's bucket, so a nav item id is unique within one app's navigation tree, + and the translation bundles are keyed `apps..navigation.`. +- 3977410: docs(mcp): the README no longer promises that Claude Desktop reaches intranet deployments — *Add custom connector* is the claude.ai connector system and dials from Anthropic's servers (#16882) + + `packages/mcp/README.md` grouped the clients by **where the client application runs**: "Local clients (Claude Code / Desktop) can reach intranet deployments; claude.ai web connectors additionally need the endpoint publicly reachable." That grouping is wrong for Claude Desktop. Its *Settings → Connectors → Add custom connector* flow is the same claude.ai connector system, and the connection to the MCP server is made **from Anthropic's servers** — Anthropic's custom-connector documentation requires the server to be reachable over the public internet from Anthropic's IP ranges and states that a server on a private corporate network, behind a VPN, or blocked by a firewall will not connect. An operator following the old sentence pointed Claude Desktop at an intranet address and the failure surfaced inside a third-party client, with nothing to connect it back to our instructions. + + The README now groups by **where the connection is made from**, which is the mechanism and does not go stale when a client's dialog is redesigned: + + - **Claude Code** (`claude mcp add`, or the plugin) dials the endpoint from your own machine, so `localhost` and intranet-only deployments work — this is the door that genuinely reaches a private deployment, and the README now names it as such. + - **claude.ai (web) and Claude Desktop** go through the one claude.ai custom-connector system and need public HTTPS; a locally trusted certificate does not make a private address reachable. + + Documentation only — no exported symbol, endpoint, schema or runtime behaviour changes. The `patch` bump is because `README.md` is in this package's published `files[]`, so the corrected text ships to the npm page. +- 46cf705: fix(mcp): refuse undeclared argument keys on every MCP tool instead of stripping them + + `query_records` answered `{"objectName":"crm_opportunity","sort":"-amount","limit":3}` with `200` + and rows in seed order, and `{"objectName":"crm_opportunity","filters":[["name","contains","Meridian"]]}` + with `200` and the full unfiltered set. Neither key is declared, and zod's strip default — reached + through the MCP SDK's raw-shape wrap — deleted both before the handler ran, so the handler could not + report what it never received. Nothing in either payload distinguished it from a real answer, and the + consumer of these tools is an AI agent: it reads a successful response and reports the wrong answer + confidently. A dropped sort key answers a differently ORDERED set; a dropped filter key answers a + WIDER one. + + All eleven tools held that posture; none refused. Each tool's `inputSchema` is now a built strict + object, so an undeclared key is refused before dispatch, the data bridge is never reached, and + `tools/list` advertises `additionalProperties: false` — the closed set is readable off the schema + rather than discoverable only by being refused. The refusal names the offending key and, where the + spelling is recognisable, the declared one to send instead. + + Spellings that used to be accepted-and-ignored, and what to send now. Every one of them was already + inert: it was dropped, and the call proceeded exactly as if it had never been sent. + + | previously sent and ignored | send instead | on | + | :-- | :-- | :-- | + | `sort`, `sortBy`, `order`, `order_by` | `orderBy` | `query_records` | + | `filters`, `filter`, `conditions`, `criteria` | `where` | `query_records` | + | `select`, `columns`, `projection` | `fields` | `query_records` | + | `pageSize`, `top`, `take` | `limit` | `query_records` | + | `skip`, `start` | `offset` | `query_records` | + | `filters`, `filter`, `conditions` | `where` | `aggregate_records` | + | `metrics`, `aggregates`, `aggs` | `aggregations` | `aggregate_records` | + | `group_by` | `groupBy` | `aggregate_records` | + | `tz`, `timeZone` | `timezone` | `aggregate_records` | + | `object`, `table` | `objectName` | every object-scoped tool | + | `id`, `record_id` | `recordId` | `get_record`, `update_record`, `delete_record`, `run_action` | + | `record`, `values`, `fields` | `data` | `create_record`, `update_record` | + | `action`, `name`, `action_name` | `actionName` | `run_action` | + | `args`, `input`, `arguments`, `parameters` | `params` | `run_action` | + | `formula`, `expr`, `cel` | `expression` | `validate_expression` | + + A key outside this table is refused with its name echoed back and a closest-declared-key suggestion + when one is within a length-relative edit distance. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 85d4ee15de..6e25216276 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.4.0", + "version": "17.5.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 490fc200c5..b9e90459a9 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,165 @@ # @objectstack/metadata-core +## 17.5.0 + +### Minor Changes + +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- cca1dc0: + + feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + + **BREAKING** — two published machine surfaces change a key name. There is **no alias + and no dual-key transition window**: one axis, one name. + + | Surface | Was | Now | + |:--|:--|:--| + | `os migrate meta --json` payload | `runtime` | `protocolVersion` | + | `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | + | `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + + The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol + major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload + moves — no other key is added, removed or reshaped, and both text faces are byte-identical. + The parameter rename is positional, so no call site changes. + + ## Why the name had to move + + `PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed + `@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* + it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or + a stale install, next to the real package versions of the same upgrade session. + + The human line was repaired first and now reads + `Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the + worse half and was left standing, because a key on a published payload is a contract + change: an agent scripting an upgrade has no prose to disambiguate at all, and the + diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer + does not parse. + + ## What a consumer should do + + Read the new key. The old one is absent, so a consumer that does not move reads + `undefined` rather than a wrong value. + + ```diff + - const v = payload.runtime; // os migrate meta --json + + const v = payload.protocolVersion; + + - const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE + + const v = err.diagnostic.protocolVersion; + ``` + + The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` + spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it + from the package install boundary, and `@objectstack/services-package` reads it during + hydration — so a consumer reading it from any of those reads the new name too. + + `runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer + protocol major, not a semver in a version position, and it does not carry the ambiguity + this rename closes. + + The breaking surface was measured before the rename and is closed inside this repository: + the only reader of the `--json` key was this repo's own e2e pin and the only reader of the + diagnostic member was `metadata-core`'s own unit test, both of which move in this same + change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever + naming the field. **Zero external consumers were found.** Graded `minor` rather than + `major` for the launch window; the banner above carries the breaking-ness the level cannot. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index fd114e4537..22beba6506 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.4.0", + "version": "17.5.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 4a06acf204..ff2993c3af 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/metadata-fs +## 17.5.0 + +### Patch Changes + +- Updated dependencies [2bed4c3] +- Updated dependencies [cca1dc0] + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 713ee5dc89..68ab9709bc 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.4.0", + "version": "17.5.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 e3e36d4f40..f63c760231 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,599 @@ # @objectstack/metadata-protocol +## 17.5.0 + +### Minor Changes + +- 04333d0: The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175) + + Two migrations on the boot hook asked "does this table exist?" with a statement + that cannot succeed when the answer is no — `SELECT "tenant_id" FROM + "_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT + 1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read + the refusal as "no". Both are correct on their own terms. Both make + `SqlDriver.execute()`'s raw terminal write the statement and the dialect's + message to the operator's log on the way out. + + Measured on this tree against real `better-sqlite3`: exactly one line per probe, + on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token + and `no such table`. It fires on **every boot** of every install that has never + allocated an autonumber, and again on every boot of every kernel that does not + register the optional `service-settings`. + + ⭐ The cost is not the line. It is that operators learn this product prints + errors when nothing is wrong, and then miss the one that matters. A consumer told + to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table` + as a failing boot) must either ignore an unactionable ERROR every boot or chase a + platform-internal probe. + + **The question is now asked of the CATALOG.** A new shared + `migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master` + for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with + `DATABASE()` for MySQL — each of which returns zero rows for a table that is not + there instead of being refused. Both migrations call it; the probe lives once, + not once per site. + + **⛔ Why not in the driver.** Quietening a refusal requires classifying it, this + repo has one predicate for that (`isMissingTableError`), and it needs the name of + the thing the caller was reading — which the raw path structurally does not have + (`rawStatementFaultError` declares no targeted table, and + `driver-error-classification.callers.test.ts` fails any in-repo call that omits + `readObject`). An unclassified demotion of the driver's raw terminal would + quieten real failures too. The caller knows the table; the driver does not. + + **⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm + mis-compiled for some dialect would be refused, caught by the same `catch` the + expected miss uses, and read as "the table is not there" — turning a stored-row + data repair into a silent no-op on whichever dialect nobody exercised. So the + probe answers four verdicts rather than a boolean, and `'unreadable'` is never + folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised + dialect gets no guessed catalog statement at all — it keeps the caller's own + `WHERE 1 = 0` probe, whose refusal is now *classified* with + `isMissingTableError(error, table)` rather than swallowed as absence. + + **Why `minor`.** + + - `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no + input a caller writes is affected; the one consumer shape that could break is + an exhaustive `switch` with a `never` default, which is why this is not a + `patch`. + - `ensureSysSettingIdentityIndex` gains an optional third parameter + (`{ client? }`). Callers that pass two arguments are unchanged and keep + today's behaviour exactly — without a client there is no catalog arm and the + pre-existing probe runs. + - `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in + text and still exported. They are no longer what the boot path runs first. + - `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are + re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and + every importer see no difference. + + **What did NOT change.** #10789's ruling stands: a seam that accepts a statement + and returns no result set still reports `absent` with the `detail` that separates + it. The driver's error channel is untouched — a statement the backend genuinely + refuses is still written to the log in full, asserted against the same driver and + the same sink in the same test as the silence. + + **Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to + end against a real `SqlDriver` (`packages/runtime`'s + `seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against + the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions + and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT + MEASURED against a live server: this package has no live-PG harness, no `pg` + dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to + `live-mysql`. Its statement text is pinned; running it is not. +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- 4062aef: fix(metadata-protocol): the runtime authoring gate judges an OVERRIDDEN item from the body the runtime serves (#16224) + + The #4463 runtime authoring gate resolves references against the live metadata universe, and since #15950 it gathers that universe from BOTH homes — the `SchemaRegistry` and `sys_metadata`. That fold was **additive**: a stored row contributed a name the registry did not carry and never displaced a registry entry. Where an org or env-wide overlay REDEFINES an item a code package already declares, the gate therefore judged that item's CONTENT from the registry's copy — a body the runtime had already stopped serving. + + Measured end to end, in one process and one instant. A code package ships `dataset/D` with measure `m`; an env-wide overlay redefines `D` without it: + + - a dashboard widget bound to `values: ['m']` was **accepted**, and the runtime cannot serve it; + - a widget bound to the measure the overlay DOES declare was **refused** `422 widget-measure-unknown`, and the runtime can. + + One cause, both directions: an acceptance that should have been a refusal and a refusal that should have been an acceptance. + + The hand-rolled additive merge is replaced by `mergePackageAwareOverlay` with `foldObjectExtendersFromRegistry` as its transform — the merge, and the transform, that `getMetaItems` (the read API behind `GET /meta/:type`) already runs. The gate's universe is now the universe the platform answers reads from, by construction rather than by agreement, and ADR-0048 package slotting arrives with it: an overlay shadows the entry it actually overrides, and two installed packages shipping one `type/name` remain two entries. + + **#15950's resolved-vs-base distinction is kept by folding, not by declining.** Its argument was never "an overlay must not win" but "an UNRESOLVED body must not win" — the registry's copy of an object is its RESOLVED schema (ADR-0029 D9.2: base layer plus its `extend` contributors), a `sys_metadata` row is the base layer alone — and it names its own remedy, which is what `getMetaItems` does to its winner. Pinned: an `object` overlay wins on its own columns AND keeps the registry's `extend` contributors. For a name the registry does not carry the result is byte-for-byte #15950's additive contribution, pinned in the same process. + + Graded `minor` rather than `patch`: `PUT /meta/:type` is a published verb and this narrows its accept set. An `active` publish that names a reference the overlay removed now answers `422 INVALID_METADATA` where it answered `200` — one legal published answer replaced by another, not the repair of a value the schema already refused. The write it now refuses is one the runtime could never serve; the write it now accepts is one the runtime always could. + +### Patch Changes + +- 07f93e0: Seed loader: the pass-2 deferred-reference diagnostics now say which moment they describe + + `SeedLoaderService` runs inside `AppPlugin.start()`, which the kernel completes for every + plugin before it fires `kernel:ready` — where the first-admin handoff + (`claimSeedOwnership`) re-owns every `owner_id IS NULL` row of every user-authored object. + That handoff is the designed completion of a NULL owner column, so two of the loader's + pass-2 lines — `Deferred reference UNRESOLVED after pass 2` and + `Deferred reference back-fill FAILED` — were making a bare present-tense claim + (`x.owner_id stays NULL`) that the same boot then made false, with nothing in either the + log or the table to tell an operator that the other reading existed. + + Both lines now read `is NULL at the end of pass 2` and carry a scope sentence naming the + boot step that can supersede them and stating that a non-NULL value found later is not + evidence the reference resolved. Level, error count and remedy are unchanged — this is a + scope declaration, not a silencing. The two `Deferred reference DROPPED` lines are + deliberately untouched: they report a row that never landed, so no later boot step can + write a column of it and their claim survives to the end of boot as written. + + Nothing an author writes changes. Anything that greps the loader's output for the literal + `stays NULL` on these two lines should grep for `is NULL at the end of pass 2` instead. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- 29d00cc: Fix `GET /meta/types` serving an empty JSON Schema for `action` + + `ActionSchema` is a `ZodPipe`, and the `output` derivation of a pipe carries no + properties, so `/meta/types` advertised `action` as + `{"$schema": "https://json-schema.org/draft/2020-12/schema"}` — a document that + reads as "this type declares no constraints" for a type that accepts 47 keys. + The hand-crafted fallback declared for this case never fired, because the + conversion did not throw: it succeeded and returned a truthy husk, which + short-circuits the `??` that was supposed to reach the fallback. + + A derivation that comes back with no properties, no union arms, no `$ref` and no + `additionalProperties` object is now treated as a non-answer. It is retried in + the authoring shape (`io: 'input'`), and if that degenerates too the type is + named in a one-shot warning and the hand-crafted fallback decides. + + Only `action` changes. The `output` derivation remains the served default on + purpose: deriving every type with `io: 'input'` was measured across the whole + served surface and would move 24 of the 26 types that carry a Zod schema, in the + direction of a weaker contract (`required` entries 1132 to 867, + `additionalProperties: false` 663 to 637). Gating the retry on degeneracy keeps + the change to the one type that was actually broken. + + Consumers reading `schema` for `action` from `/meta/types` or `/api/v1/meta` now + receive its real 47 properties instead of an empty object. No other type's + served payload moves, and a type that resolves no Zod schema at all continues to + be served with no schema — absence is not the same failure as a derivation that + came back empty. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is + `seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; + that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and + whether it should go is tracked by #17167. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [0fb6f97] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [86f4246] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [e958468] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [6e3462d] +- Updated dependencies [31064ca] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [f89dd33] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [e4fd55d] +- Updated dependencies [7026141] +- Updated dependencies [ba17017] +- Updated dependencies [ecdfc94] +- Updated dependencies [131851f] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [4ecfd2b] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/lint@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 933ecffcd2..8b1273a44f 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.4.0", + "version": "17.5.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 24f3f39908..583b47bbad 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,401 @@ # @objectstack/metadata +## 17.5.0 + +### Minor Changes + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- b110578: fix(metadata): four `isoFromValidDate` call sites collapse onto the shared canonical-ISO spelling; `MetadataHistoryRecord.recordedAt` gets the terminal value it never had (#16422) + + ## What was wrong + + `#14037`/`#14038` landed a narrow per-site helper, `isoFromValidDate`, beside + the shared `canonicalIsoInstant` spelling. It rewrote exactly one shape — a + valid JS `Date` becomes ISO text — and handed **every other input back + untouched**. Four adapter boundaries used it, and each fed a field declared + `z.string()` or `z.string().datetime()`: + + | site | declared as | + |:--|:--| + | `SysMetadataRepository.rowToEvent` → `MetadataEvent.ts` | `z.string()` | + | `DatabaseLoader.rowToRecord` → `MetadataRecord.createdAt` / `.updatedAt` | `z.string().datetime().optional()` | + | `DatabaseLoader.getHistoryRecord` → `MetadataHistoryRecord.recordedAt` | `z.string().datetime()` — **required** | + | `DatabaseLoader.queryHistory` → the same field, the other door | `z.string().datetime()` — **required** | + + So a `null`, a `number`, an opaque column and an Invalid `Date` all arrived at a + field declared `string`, each wearing an `as string` / `as string | undefined` + cast that asserted the opposite. Measured over the seven inputs that + distinguish the two helpers, the declared schemas refused **21 of 35** produced + values. + + `recordedAt` was the sharp end: a REQUIRED `z.string().datetime()` for which + none of the three available answers was legal — the visible text + `"Invalid Date"` fails the refinement, `undefined` fails the required field, and + the pass-through fed it the `Date` object, which fails both. + + ## What it does now + + Those four sites read `canonicalIsoInstant`, whose return type **is** + `string | undefined`, so all four casts are deleted rather than restated. Both + sibling definitions of `isoFromValidDate` are gone. The terminal value is chosen + per site, from the site's own declared schema: + + - `MetadataRecord.createdAt` / `.updatedAt` are `.optional()` → `undefined`, the + branch an absent column already took. ⛔ No default is invented for a field the + schema lets be absent. + - `MetadataHistoryRecord.recordedAt` is required → the **epoch**, via a named + `recordedAtFallback()` shared by both history doors. ⛔ Not `new Date()`: a + `now` stamp is a plausible-looking recording instant nobody measured, and it + sorts a version recorded years ago to the top of a newest-first timeline. The + epoch invents no fact and sorts to the oldest end. It is also the answer the + sibling reader of this same `sys_metadata_history.recorded_at` column already + gives (`rowToEvent` and `history()`, both `?? new Date(0).toISOString()`). + + Schema refusals over the same seven inputs: **21 → 8**. The eight that remain + are a `number` and an opaque object at four sites — shapes no driver is measured + to materialise for these columns. They now arrive as the declared *type* (a + string) that simply is not a valid datetime, so the producer's bug stays visible + instead of being papered over. + + ## One behaviour change worth reading twice — and it is why this is `minor` + + `DatabaseLoader.stat()` computes `record.updatedAt ?? record.createdAt`. An + Invalid `updated_at` used to WIN that `??` — a `Date` is truthy and not nullish — + so a row with an unreadable `updated_at` and a good `created_at` published + `new Date()` as its `mtime`. It now folds to `undefined` one step earlier and + loses the `??`, so the row publishes its `created_at`: a stored instant in place + of a fabricated one, and exactly the "same `?? DEFAULT` chain an absent column + takes" that `#14078`'s own ruling text prescribes for the shape. + + ⚠️ **The old answer was LEGAL.** `new Date().toISOString()` satisfies + `MetadataStats.mtime`'s `z.string().datetime()` perfectly well, and the + pre-existing pin asserted exactly that. So this one site is **not** the repair of + a violation — it is one legal published answer replaced by a different legal + published answer on a published read verb. Nothing was refused before and is + permitted now; a consumer simply receives a different instant. + + ## Why the two levels differ + + - **`@objectstack/metadata` — `minor`.** Its four repaired sites, on their own, + are the "repairing an implementation that silently violated its own already + published declared type" case: the values that changed there are ones + `MetadataRecordSchema` / `MetadataHistoryRecordSchema` already refused, and + nothing a consumer legitimately received has moved. But this package also + carries `stat()`, and that site changes a **legal** published answer, which the + paragraph above measures. The level is per package, so the four repaired sites + ride along at `minor`. + - **`@objectstack/metadata-protocol` — `patch`.** Neither of its two sites moves + a legal published answer. `rowToEvent` only stops emitting values + `MetadataEventSchema` refused (a `Date`, a `number`, an opaque object in a + field declared `z.string()`), and `listCommits` is byte-identical on all seven + probe inputs. + + ⛔ No declared type narrowed, no export was added or removed (neither helper was + ever exported), and no envelope or accept set moved — so this is `minor` by the + changed-answer row, not a breaking change, and it carries no ADR-0087 + disposition. + + ## What deliberately did NOT collapse + + `listCommits` in `@objectstack/metadata-protocol` keeps its copy. Its docblock + promises callers the RAW value back for a non-`Date`, and the shared spelling + rewrites the whole domain: swapping it in would ERASE an Invalid `Date` from the + response (`undefined` — the one answer ADR-0053 D-F3 refuses, because it silently + drops a value that is on disk) and hand a `number` or an opaque object to the + commit-timeline sort as `String(value)` rather than verbatim. Measured, that site + is byte-identical on all seven inputs before and after this change. + + `SqlDriver`'s same-named helper is not part of this family at all: it takes + `Date` (not `unknown`), both its call sites narrow with `instanceof Date` first, + and it is the PRODUCER-side fold ADR-0053 D-F3 governs. It is untouched. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + + +### Patch Changes + +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is + `seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; + that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and + whether it should go is tracked by #17167. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/metadata-fs@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index d4d066c575..6094b1108a 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.4.0", + "version": "17.5.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 987279e56b..256d55f198 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,669 @@ # @objectstack/objectql +## 17.5.0 + +### Minor Changes + +- f03f6c7: fix(driver-memory)!: an analytics time dimension buckets by its declared `granularity`, and refuses a sub-day one instead of ignoring it (#16178) + + + + **BREAKING** in three senses, all on `driver-memory`'s analytics face, landing in + the launch window as `minor` under the lockstep convention this cluster's + siblings already use: + + - an accepted request now answers **differently**: a time dimension carrying a + `granularity` folds its rows into calendar buckets instead of returning one + group per distinct timestamp. Every affected answer was wrong before; + - a **trend query answers rows where it used to answer one total**: a + `granularity` on a member `dimensions` does not also list is now a group + column of its own, so `{measures, timeDimensions: [{dimension, granularity}]}` + — the canonical trend shape — comes back one row per bucket, carrying the + member and a `fields` entry for it, instead of a single ungrouped total with + no such column; + - an accepted request is now **refused**: `granularity: 'second' | 'minute' | + 'hour'` answers `NOT_IMPLEMENTED` / 501 instead of being silently dropped. + + ## What was wrong + + `AnalyticsQuery.timeDimensions[].granularity` is declared by the spec and a cube + dimension enumerates the granularities it offers (`granularities: ['day']`). + `memory-analytics.ts` read neither. The `$group` stage keyed on the raw field + path, so a time dimension bucketed **one group per distinct timestamp** — one bar + per row in a "new accounts by month" chart, which is the symptom #3588 + catalogued and repaired for `service-analytics`. + + Measured through the public entry against the built package, two rows on one UTC + calendar day (`2026-09-06T01:00:00Z` and `2026-09-06T23:00:00Z`) under + `granularity: 'day'`: + + | | before | after | + |:--|--:|--:| + | `granularity: 'day'` | **2 groups**, keyed on the raw instants | 1 group, `2026-09-06` | + | no granularity (control) | 2 groups | 2 groups, unchanged | + | `granularity: 'hour'` | **2 groups**, silently | `NOT_IMPLEMENTED` / 501 | + | same, but with no `dimensions` | **`{count: 2}`** — one total, no time column, and no `fields` entry naming it | `{'events.createdAt': '2026-09-06', count: 2}`, `fields` naming both | + | `granularity: 'fortnight'` past the schema door | — | `INVALID_QUERY` / 400 | + + The emitted pipeline was byte-identical across all three, which is the whole + finding: the request was accepted, no warning was emitted, and the key was inert. + + ## What it does now + + - **One forward labeller, in `@objectstack/core`.** `bucketDateKey(value, + granularity, timezone)` sits beside the inverse `bucketKeyToCalendarRange` and + the `calendarPartsInTzOrUtc` primitive it builds on, and it is now the only + statement of the rule. `BUCKET_GRANULARITIES` and `isBucketGranularity` name + the five granularities that HAVE a canonical key, so a face that must refuse + the other three quotes the accepted set instead of hand-listing it. + - **`@objectstack/objectql`'s `bucketDateValue` is a delegate**, export name and + signature unchanged, answers unchanged — pinned across granularity, timezone + and input form rather than asserted. A driver that pushes the bucket down into + SQL and this in-memory path must label one instant identically or a drill-down + breaks at the seam, and that is now one function rather than an agreement + between two. + - **A granular time dimension is a group column, listed or not.** `dimensions` + no longer decides alone what `$group` keys on: every `timeDimensions` entry + carrying a `granularity` is grouped, projected and named in `fields`, deduped + against `dimensions` on the resolved member so two spellings of one member + stay one column. This is the rule the SQL/ObjectQL face already records + (`projectedDimensions`, #4033/#5688) — one set feeding grouping, row mapping + and field metadata, because rows carrying a bucket under a `fields` list that + never mentions it is a trend chart with no x-axis. ⛔ An entry carrying only a + `dateRange` is a predicate and is still **not** projected. + - **`driver-memory` folds by granularity before its `$group`.** The pipeline is + cut at that stage: the `$match` half still runs in the driver, the bucket keys + are written onto the selected rows, and the grouping half runs over those. The + key travels under a synthetic field rather than overwriting the row's own, so a + member that is both a group key and a measure's aggregand still ranks instants + in `max()` while grouping on the label. + - **The output vocabulary is the published one** — `2026`, `2026-Q3`, `2026-09`, + `2026-09-06`, `2026-W36`. The week label is `YYYY-Www`, never the Monday's + `YYYY-MM-DD`: `DriverCapabilitiesSchema.queryDateGranularity` calls this an + output contract, and a second spelling is what breaks a drill-down across a + backend seam. + - **Bucketing honours `AnalyticsQuery.timezone`** — the same reference zone + #16042 threaded through the `dateRange` window resolver, so the window that + selects the rows and the bucket that folds them agree on where a calendar day + starts. The same two rows answer one group in UTC, two in `America/New_York` + and two in `Asia/Tokyo`. An absent zone buckets in UTC, the resolver's default. + + ⚠️ That agreement is about the PRESET arm of `dateRange`, which the resolver + reads in the reference zone. An explicit `[start, end]` array is the caller's + own **instant** window and keeps its published reading (#16179), while the + bucket beside it is always a **calendar** label (ADR-0053) — so an array + window and a bucket can still disagree about where a day starts. That + combination is legitimate and is not refused; it is stated here rather than + left to be discovered. + - **`second` / `minute` / `hour` are refused at compile**, in the ADR-0112 + envelope this driver's other capability gaps speak (`NOT_IMPLEMENTED` / 501, + the class `refusePerAggregationFilter` uses for the same reason: the query is + spelled correctly, the spec declares the value, and it is this backend that + compiles nothing for it). The canonical key vocabulary defines no label for a + sub-day bucket, so there is no string another backend's pushed-down SQL would + agree with. Passing it through unbucketed is this card's own defect wearing a + new name. + - **An undeclared granularity is a 400, not a 501.** A 501 says "this backend + cannot", which is only honest about a value the contract declares. + `TimeUpdateInterval` is checked first, so a spelling it never declared — + reachable past the schema door, where `POST /analytics/dataset/query` types + `selection.timeDimensions` without Zod-parsing them — answers `INVALID_QUERY` + / 400 rather than a 501 asserting the spec declared it. The same separation + the `dateRange` half of this face already draws (#16322 / #16041). + + ## If a caller is refused + + A stored widget or a request asking for a sub-day granularity was never bucketed + by this backend — it received one group per distinct timestamp under an ordinary + 200. Nothing that worked stops working. Ask for `day` or coarser and the answer + is a real bucket; keep the raw timestamps deliberately by dropping the key, which + is the behaviour that key used to produce by accident. +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 2bed4c3: fix(objectql)!: a field whose `type` is absent or is not a `FieldType` member is refused at the registration door, and every downstream family default becomes a refusal (#16319) + + + + **BREAKING** for stored metadata only: an object whose declaration carries a field with no `type`, or with a `type` that is not a `FieldType` member, **no longer loads**. Shipped as `minor` under the repo's launch-window convention. Maintainer ruling, 2026-09-10, verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」. + + **What you have to do.** Nothing, unless a `sys_metadata` row in your deployment carries such a field. If one does, the startup log names it at `error` level — object, field and reason — and the row is left untouched and still reachable: open it in Studio and give the field a real `FieldType` member, or delete it (`DELETE /api/v1/metadata/object/NAME`). Nothing that passes `FieldSchema` is affected: it has always required `type` and always refused a non-member, so only the doors that skip Zod could ever deliver one. + + ## What was wrong + + One declaration produced two different columns. Measured on live PostgreSQL 16.13, driving all three producers from one object: + + | declaration | driver | `os generate migration --format sql` | `--format ts` | + |:---|:---|:---|:---| + | `{ maxLength: 100 }`, no `type` | `character varying(100)` | `TEXT` | `TEXT` | + | `{ type: 'this_is_not_a_field_type', maxLength: 100 }` | `character varying(255)` | `TEXT` | `TEXT` | + + `SqlDriver.createColumn` read `field.type || 'string'`, which heads its STRING-family arm and sizes the column from the declared `maxLength` (knex's 255 without one). All four generator loops in `os generate` read `String(fieldDef.type || 'text')`, which heads the TEXT family — unbounded unless the column is keyed. Both directions of harm are in the first row: the platform refuses a 101-character value that both generated tables accept, and a table generated from the same object accepts values the platform will not store. + + ## What it does now + + - **One point of closure, at the registration door.** `SchemaRegistry.registerObject` refuses the WHOLE object declaration, with the ADR-0112 envelope (`INVALID_METADATA` + `422`), naming the object, the field and the reason — and offering the spec's own "did you mean?" for a mis-spelling. ⛔ The offending field is never dropped on its own: an object loaded one field short reports success at every authoring surface while the column is never created and every read of it answers `undefined`. Every door goes through this one — declared stacks, package and plugin manifests, `saveMetaItem`, the `sys_metadata` boot rehydration, and raw `registerObject` calls — and all three contributor kinds (`own`, `overlay`, `extend`) are judged, because `ObjectSchema.fields` and `ObjectExtensionSchema.fields` are both `z.record(z.string(), FieldSchema)`. + - **The startup policy is revised for this class.** `loadMetaFromDb`'s 「Registered anyway so it stays serveable and fixable」 no longer applies to it. The row does not register; the startup log states the consequence and the fix once, at `error`. The row itself is untouched, and the metadata API's raw-row path still lists it, still serves it with the offending field visible, still accepts a corrected write, and still deletes it — pinned, because a refused row that vanished from Studio would be unfixable. + - **Downstream guesses become refusals.** `createColumn` refuses a field that declares no `type` instead of building `varchar(255)` for it. All four `os generate` loops — both migration formats and both `os generate types` loops — refuse an absent or non-member `type` and generate nothing for that object, rather than emitting a table one column short. `fieldTypeToSql`'s docblock is rewritten in the same stroke: its `TEXT` miss branch is now dead residue of a total table, ⛔ not a family default to route anything new to. + + ## Scope, stated rather than left to be inferred + + `SqlDriver.createColumn` refuses `type` ABSENCE, not `FieldType` MEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route into `syncSchema`, so a non-member cannot reach the driver from a runtime at all. `driver-sql`'s own test corpus declares 388 non-member spellings across ~100 files that drive `initObjects` directly, and `'string'` is a declared `case` arm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, deliberately not folded into this change. A pin holds the boundary in both directions. + + ONE fixture in that corpus is migrated here, because it is the one that crosses the door. `CROSS_FIELD_OBJECT_FIELDS` — exported from this package's root, so a published export and not only a local literal — declared `stage` and `owner` as `'string'`. Four of its five consumers hand it to `driver.initObjects`, which the paragraph above leaves alone; the fifth hands it to `ql.registerObject`, which now refuses the whole object. Both fields are re-spelled `'text'`. That is not a re-typing: `canonicalizeSqlType('varchar(255)')` is `'text'` and `suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in `spec/data/type-compat.test.ts`, so `'text'` is the spelling of the column `'string'` was already producing. It does move the emitted column from `varchar(255)` to `TEXT` (measured on sqlite-wasm: `stage varchar(255)` becomes `stage text`), which is inert for this fixture — no index keys either column, `initObjects` is passed no indexes, and the corpus's longest value in them is four characters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 5c8f5af: feat(engine): `ObjectRepository.findOne` / `.update` publish their honest types — the contract's shapes, not `any` (#16786) + + **BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #15280 used for `SqlDriver.update()` and the `TursoDriver.update()` override, and PR #14434 before it on `@objectstack/driver-memory`). + + `ObjectRepository.findOne()` and `.update()` were written out with an explicit `Promise` while they have always answered what the contract declares — each one forwards, one line down, to an `IDataEngine` door that already declares the shape: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + + `IScopedObjectRepository` — the contract this class carries an `implements` clause for — declares both, and has since ruling A on #16231 landed (PR #16783). An explicit `any` satisfies that structurally, because a **wider** declared return always satisfies a narrower one: `class ObjectRepository implements IScopedObjectRepository` compiled green the whole time while the emitted `.d.ts` read `Promise`, so no caller holding an `ObjectRepository` — or reaching one through `ScopedContext` or `ObjectQL.createContext()`, both exported from this package's index — was ever asked to narrow. They are now declared as the contract declares them. No runtime behaviour changes. + + A caller that read fields off `findOne()`'s result through the `any` now narrows the `null` arm first; a caller that read `update()`'s result now separates the by-id record from the predicate-form count. The in-repo census for this change was one file, repaired alongside. + + `updateById` is deliberately untouched: `IScopedObjectRepository.updateById` itself declares `Promise`, so the class already matches its contract and there is no drift to repair on this side. That half stays open on #16786. + + +- 5b5bd36: fix(objectql)!: the create-side static-`readonly` strip judges the user-writable `managedBy` buckets, as update already did (#15719) + + + + **BREAKING** for a non-system caller that CREATES a static `readonly` column on an + object declaring `managedBy: 'platform'`, `'config'` or `'system-data'` under a name + outside the reserved `sys_` namespace: the forged value used to be persisted and is + now stripped, with the field's own `defaultValue` re-derived (#3043) and the drop + reported on the usual channels (`readonlyStripWarning` at `warn`, `onFieldsDropped` + under reason `readonly`, `strictReadonlyWrites` refusing before any driver dispatch). + That is exactly what the same caller's UPDATE of the same column already did. Shipped + as `minor` under the repo's launch-window convention. + + ## The census, both halves — neither one is the whole reading + + **(b) is greater than zero, so the affected objects are named.** 20 shipped objects sit + in the three now-judged buckets and carry a static `readonly` column between them — 64 + columns in all: + + - `platform` (6 objects, 14 columns): `sys_attachment`, `sys_business_unit`, + `sys_business_unit_member`, `sys_comment`, `sys_report_schedule`, `sys_saved_report` + - `config` (6 objects, 29 columns): `sys_capability`, `sys_email_template`, + `sys_permission_set`, `sys_position`, `sys_sharing_rule`, `sys_webhook` + - `system-data` (8 objects, 21 columns): `sys_approval_delegation`, + `sys_notification_preference`, `sys_notification_subscription`, + `sys_notification_template`, `sys_position_permission_set`, + `sys_user_permission_set`, `sys_user_position`, `sys_user_preference` + + **And the shipped behaviour delta is ZERO.** Of the 81 object declarations in this tree + carrying `managedBy`, **none** is named outside `sys_` — every one of the 20 above + included — so the namespace test, which this change does not touch, keeps all of them + exempt exactly as before. `sys_metadata_history.recorded_by`, seeded by a direct + non-system `engine.insert` from the metadata repository, is doubly exempt + (`engine-owned` bucket **and** `sys_`) and is pinned as such. + + ⚠️ **Read both halves together.** "Behaviour-free" on its own overstates it — the + population the narrowing reaches is real and named above, and an app that declares one + of those buckets on its own object gets the strip. The population on its own + understates it — not one shipped object changes behaviour on this release. What moves + is the contract for **app-authored** objects, which is the population the ruling is + about. + + ## What was wrong + + `staticReadonlyInsertSubject` returned `null` for `managedBy` set to **anything**, + carried over byte-for-byte from the deleted DataProtocol ingress copy on ADR-0086 / + #3004 grounds: those columns have their own 403 guards, and a silent strip must not + swallow the payload the guard exists to reject. The argument is sound and the bucket + list was not. `managedBy: 'system-data'` means "platform-defined schema, + **admin/user-writable data**" by its own definition, and `object.zod.ts` says in the + same breath that it "carries no such guard; its writes are adjudicated by the + delegated-admin gate / RLS / permission sets". So the create side skipped the strip on + objects whose data is the user's, while the update side stripped them — and #14147's + "one semantics, one enforcement point" was not literally true on that population. + + ## What it does now + + The exclusion follows its reason. `null` is returned for the `sys_` namespace, and for + the three buckets whose columns really do carry a fail-closed refusal: + + | bucket | its own refusal | the create-side strip | + |:--|:--|:--| + | `engine-owned` | ADR-0103 engine-owned write guard | steps around it | + | `append-only` | ADR-0103, same guard (locked default) | steps around it | + | `better-auth` | ADR-0092 identity write guard | steps around it | + | `platform` | none — full user CRUD by default | judges it | + | `config` | none — admin-authored, writable by default | judges it | + | `system-data` | none — "admin/user-writable DATA" | judges it | + + An **unrecognised** bucket value is deliberately not read as platform-internal: the one + legacy value that can still arrive is `'system'`, retired in protocol 17 (#3355) and + converted to `'system-data'` — a judging bucket — so exempting unknowns would exempt + precisely the rows that conversion targets. The partition is pinned against + `@objectstack/spec`'s own enum, so a seventh bucket fails a test instead of landing + silently on one side. + + The ruling's fallback ("leave it, if those buckets' readonly columns already carry + their own 403") does not apply: of the 64 columns above, 14 are the ADR-0086 + package-provenance family (`package_id`, `managed_by`, `customized`, `drift_status`, + `drift_detail`, `is_system`, all on `config` objects) and the other 50 are `id` / + `created_at` / `updated_at` stamps, which that guard does not reach. + + `@objectstack/lint` mirrors this predicate to decide which objects its create-verb + `flow-update-readonly-field` / `hook-api-update-readonly-field` findings may describe, + and is narrowed in the same stroke — a lint that kept the wider exemption would go on + suppressing findings for a strip that now really happens. + + ⛔ The UPDATE path is untouched, and so is `beforeInsert`'s post-hook strip position. + The asymmetry is closed by moving CREATE toward UPDATE. + +### Patch Changes + +- 0780e88: fix(objectql): a refused write reports at `warn`, not `error` — the caller was already told (#17052) + + `insert`, `update` and `delete` each end their `catch` with `throw e`, then + logged the failure at ERROR one statement earlier. AGENTS.md → *Degradation log + levels* names that exact shape and forbids it: "a failure handed to the CALLER + is not a degradation at all … Do not bolt a `logger.error` onto such a site." + + **This moves published behaviour**, which is why it is a changeset rather than a + `skip-changeset`: the level is what an operator greps, and at least one consumer + reads it structurally. `scripts/publish-smoke.sh` fails a boot on any + error-level line (`SMOKE_ERROR_LOG_PATTERN`), and that is how the defect was + found — `@better-auth/oauth-provider` seeds `sys_oauth_resource` in `insertOnly` + mode and documents its `identifier` UNIQUE constraint AS its race-safety + mechanism, catching the collision and continuing at `debug`. Our line was + emitted before that catch ever ran, so a healthy first boot of every fresh + `create-objectstack` project printed `ERROR Insert operation failed` and red-lit + `publish-smoke / packed-tarballs` for six consecutive runs on a candidate whose + auth and CRUD probes were all green. + + **Nothing else about the entry moved.** Same message, same `object` meta, same + redaction (#8682: the bound statement and its values stay cut from `message` + and `stack`), same subject (#14095: the entry carries the driver's own error — + a `DuplicateRecordError`'s `cause` — never the envelope, so the failing column, + MySQL's index name and the driver's frames survive). The `Logger` contract gives + an `Error` slot to `error`/`fatal` only, so the engine now builds the + `{ error: { message, stack } }` bag that slot used to build; handing the Error + to `warn` as meta would have serialised `{}`, because those two fields are + non-enumerable. The rendered line is byte-identical apart from the level word, + and that equivalence is pinned rather than asserted. + + If you grep your logs for these three messages, keep the message and drop the + level from the pattern. If you alert on error-level lines from `@objectstack/objectql`, + a refused write no longer raises one — the write's exception still does. +- 706ad0f: fix(objectql): a hook that faults reaching through a withheld read-only key now names the key, says the platform withheld it, and points at `ctx.previous` (#17219) + + Since #16344 the update path hides a caller-supplied static `readonly` value from `before*` hooks. A hook body that reaches **through** such a key — `ctx.input.locked_meta.who = 'hook'`, where `locked_meta` is a caller-supplied read-only `json` column — therefore dereferences `undefined` and throws, and a `body` hook's default `onError: abort` refuses the caller's whole write. + + **The refusal is correct and is unchanged.** What it replaced is a write that succeeded while persisting a value derived from the caller's forgery, and #16344 exists to close exactly that route. What this fixes is the diagnostic. Measured before this change, at both doors: + + ``` + direct SandboxError: hook 'guard_task_body' threw: + TypeError: cannot set property 'who' of undefined + REST 500 {"error":"Internal server error","code":"INTERNAL_ERROR"} + ``` + + The REST reading is the one that matters, and it is the worse of the two: a leading `TypeError:` is correctly classified as a script fault and sanitised (#7543), so an author was told nothing at all — not which key, not that the platform had taken it away, not what to read instead. + + ### Who is affected + + Anyone whose `beforeUpdate` hook reads a read-only field that the caller may also send. The write was already being refused; only the message changes. A hook that needs the stored value reads it from **`ctx.previous.`** — the same remedy PR #17195's changeset documents. + + ### What the message says now + + ``` + A `beforeUpdate` hook faulted while `locked_meta` was withheld from it. That field is + `readonly: true`, and the engine withholds a caller-supplied value for a read-only field + from `beforeUpdate` hooks, so `ctx.input.locked_meta` reads `undefined` — withheld by the + platform, not missing by accident. Read the stored value from `ctx.previous.locked_meta` + instead. Original fault: TypeError: cannot set property 'who' of undefined + ``` + + The error declares **HTTP 400**, which is what carries it past the script-fault sanitiser onto the same "message verbatim" channel a body's own authored refusal already rides; REST callers who previously saw `500 INTERNAL_ERROR` for this case now see 400 with the text above. The original fault is carried inside the message rather than replaced. + + ### Deliberate limits + + No new error code is registered and no key is added to any published payload — a dedicated `ERROR_CODE_LEDGER` entry for this refusal is a separate decision. The explanation claims only what is knowable at the seam: *faulted while these keys were withheld*, never a proven cause. An **authored** refusal (`throw new Error('…')`) is never rewritten, and a crash on an operation where nothing was withheld passes through untouched. +- 0f38ab0: fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729) + + ## What was wrong + + `InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever + schema THAT call happened to carry. A second registration without a `tenancy` + block — the `{ name, fields }` shape — fell through to the implicit + `organization_id` heuristic, so a `unique` field moved from **one row per + install** (`scopeField: null`, which is what `tenancy.enabled: false` declares) + to **one row per organization**. A duplicate the declaration refuses then + landed. Measured at the driver door on `origin/main` `d61139f1ba`: + + | sequence | second `key: 'K'`, different organization | + |:--|:--| + | register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 | + | …then re-register with `{ name, fields }` | **`LANDED`** | + + `SqlDriver` running the same sequence refuses in **both** cases: it has kept a + sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner + `computeTenantField` and not the wrapper that consults the record, so "mirrors + `computeTenantField` arm for arm" stayed literally true while the pair diverged. + + It is silent in both directions — nothing logs the flip, and the refusal names + the field, never the partition. That is the declared-vs-enforced shape Prime + Directive #10 forbids, reached by a state change rather than by a missing check. + + ## What it does now + + - **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the + sticky resolver, and the `TenantOptOutRecord` type for the per-instance record + a driver owns. `InMemoryDriver` holds one and resolves through it, handing + BOTH declaration surfaces — field-level `unique` and declared `indexes[]` — + the same resolved column. `uniqueConstraintsFromFields` and + `uniqueConstraintsFromDeclaredIndexes` accept that column as an optional + second argument; called with one argument they answer exactly as before. + `tenantFieldOf` is unchanged and still a pure function of its argument. + - **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with + the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy` + block gave a shard an organization key part the base table's index does not + have — one object, two partitions, decided by which physical table a row + landed in. It now resolves through the record, keyed by the base table. + - **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The + Archiver hands that object straight to `cold.syncSchema`, and the published + type refused the key while the driver below read it — so an author writing a + fresh literal was pushed into producing exactly the partial re-registration + above. Same correction #16711 made where the shard leaf narrowed the key off + the object it was handed. + + The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a + declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object + that never declared the opt-out never enters the record, so a genuinely + org-scoped object keeps its `organization_id` partition across a partial + re-registration — an implementation answering `null` more often would not be + stickier, it would be tenant isolation switched off. A carried `tenancy` block + stays authoritative in both directions and CLEARS a recorded opt-out. + + `@objectstack/driver-memory` is `minor` for the two new public-entry exports. + The behaviour repairs themselves are `patch`: each restores an implementation to + the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was + already declaring, rather than replacing one legal published answer with + another. The `objectql` entry is a published type WIDENING — a key the interface + refused is now accepted, and nothing that compiled before stops compiling. +- 980dc78: fix(objectql): `engine.aggregate`'s in-memory lowering asks the driver for ROWS, so a per-aggregation `filter` stops being refused by the driver it was lowered for (#16642) + + `engine.aggregate` forks: a driver with a native `aggregate()` gets the pushdown, and anything the pushdown cannot express — a per-aggregation `filter` (#10576), a date granularity the driver does not advertise, a non-UTC reference timezone — falls back to `driver.find()` plus `applyInMemoryAggregation`. That fallback handed `find()` the whole aggregate AST, **aggregation keys included**. + + `find()`'s contract says nothing about `groupBy` / `aggregations`, and the drivers disagree about them. `driver-sql` and `driver-rest` ignore both and return rows — which is the only reason this path ever worked. `driver-memory` **honours** them (`find()` → `performAggregation`, the same method its `aggregate(AST)` door funnels through), which is the shape measured here; `driver-mongodb` and `driver-turso` carry the same refusal on their own aggregation faces, so a driver that ever routes `find()` into one lands in the same place. Against a driver of the second kind the one seam answered two different wrong things: + + - the per-aggregation `filter` that **routed the call here** was refused `NOT_IMPLEMENTED`/501 by the driver's own #10413 guard — a guard aimed at a caller reaching the driver's aggregation face directly, whose remedy text is *"route the query through the engine"*. The engine's own lowering was being told to use the engine. Downstream, `service-analytics`'s ObjectQL strategy lowers a dataset measure `filter` into exactly this key, so on the memory driver a measure `filter` (and the `derived: { op: 'ratio' }` that needs two differently-filtered counts) answered **501** while sqlite answered the number; + - a date-bucketed `groupBy` came back **already grouped**, on the raw timestamp — `dateGranularity` is an engine concept no driver face reads — and `applyInMemoryAggregation` then aggregated those group rows a second time. That half does not refuse: it reports a count of *buckets* under the author's own measure name. + + The fix is one seam: on the in-memory path the AST sent to `find()` carries no `groupBy`, no `aggregations` and no `having` — the three things this path is about to evaluate itself. `where` is untouched, so the middleware-injected read scope (RLS / tenancy) still travels with the call. + + `patch`: no signature moves and no key is added or retired. The pushdown fork is unchanged (an aggregation with no filter still goes to `drv.aggregate`), and on `driver-sql` — which ignored the stripped keys — the emitted statement and every number are unchanged. What changes is that two shapes that used to answer a refusal or a wrong number now answer the number the contract already promised: `driver-memory`'s `refusePerAggregationFilter` and `driver-sql`'s `unsupportedAggregationFilterError` both document themselves as *unreachable through `engine.aggregate`, which lowers in memory for every driver* — this is the line that makes that true. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- 8c9bd8f: docs(metadata-protocol,objectql,cli): comments describing the standalone stamp now name `env_local`, the value the tree actually produces + + The v5.0 `project` to `environment` rename reached the two remaining stamps in `@objectstack/runtime` and `@objectstack/metadata` in a previous release: `createStandaloneStack` and `MetadataPlugin` both stamp **`env_local`**. Six comments in three other packages still described that stamp as `'proj_local'`, so they named a value nothing in the tree produces any more. + + No behaviour changes. The reason this is a `patch` rather than a no-publish diff is measured, not assumed: two of the six sites are TSDoc on **exported** interface members (`AssembleMetadataProtocolOptions.runPlatformMigrations`, `ObjectQLPluginOptions.runPlatformMigrations`) and land in the shipped `dist/*.d.ts`, and the `@objectstack/cli` site lands in the shipped `dist/utils/schema-migrate.js` because that package builds with `removeComments` unset. All three packages ship `dist` in `files[]`, so the corrected text is what an author reads on hover after upgrading. + + The sites were judged individually rather than search-and-replaced, because they are not all the same edit: + + - Five sites whose verb describing the stamp is present indicative describe today's tree — two of them point the reader at `runtime/src/standalone-stack.ts` to go and look — and take the current spelling. + - `packages/cli/src/utils/schema-migrate.ts` names `'proj_local'` as the value the historical arming deduction consumed. There the literal is preserved as history and its present-tense relative clause moves into the past, with today's spelling named beside it; rewriting it to `env_local` would have falsified the record in the other direction. + + The causal claim at every site is about **presence**, not spelling: the retired gate read `environmentId === undefined`, so it would have misfired identically under either literal. That reading is preserved at all six. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [b110578] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [4062aef] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7b2710d80d..bdca9b8a92 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.4.0", + "version": "17.5.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 c4ffbf7402..35a030ceb9 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/observability +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 18dee54e61..2416adf31d 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.4.0", + "version": "17.5.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 329fc74552..870a28570b 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,167 @@ # @objectstack/platform-objects +## 17.5.0 + +### Minor Changes + +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + + +### Patch Changes + +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 4215417: `sys_user.role`'s field description and its `readonly` comment stop pointing at the retired Set Platform Role action (#15188) + + Both strings named `set_user_role` / "Set Platform Role", an action retired in #9968 — the description told an operator to press a button that no longer exists anywhere in the product. This is not a source comment: a field `description` is authored data that ships in the published bundle and is extracted into the i18n bundles, so it surfaces in the admin UI's field help and in generated reference material. The correct path was already there and already the only one: platform-admin standing comes from an unscoped `admin_full_access` grant in `sys_user_permission_set` (ADR-0068 D2), which is exactly what the #9968 removal note in the same file says. + + - **`description`** now reads "Legacy better-auth role scalar (admin, user, …). ObjectStack no longer writes it (ADR-0068 D2) — grant platform-admin standing with an unscoped `admin_full_access` assignment in `sys_user_permission_set`." It states what the column IS (a vendor authentication-layer scalar that stays published as `user.role`) and where the operator actually goes, and it deliberately does not claim the scalar confers nothing: `judgePlatformAdmin` still reads `user.role === 'admin'` as the legacy fallback it has always been, so a pre-D2 deployment carrying the value is not locked out. Saying "this field grants nothing" would have replaced one false sentence with another. + - **The `readonly` comment** keeps its ADR-0092 anchor and now states the true reason the field is not editable — nothing writes it since #9968 — instead of naming a writer that is gone. + - **`en.objects.generated.ts`** follows by regeneration (`pnpm i18n:extract`), not by hand: the default locale's leaves are rewritten from the source on every run. + + **Deliberately unchanged, and pinned so it stays that way.** The same file carries a third mention inside the #9968 removal note — *"a working \"Set Platform Role\" button **was** a supported, one-user-at-a-time resurrection channel…"*. It is past tense, it narrates what was removed, and it is true; sweeping it up with the other two would turn a true sentence false. A new test pins the removal note's tombstone opener and that past-tense sentence as occurrence counts over the source text, so both directions fail: deleting the history drops a count to 0, and re-introducing the retired action's name in live prose pushes one past 1. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 9be7140e04..f4196af6f5 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.4.0", + "version": "17.5.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 b4d6799c82..6882c4540d 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/embedder-openai +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 3e29a6a985..69e2a84bcf 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.4.0", + "version": "17.5.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 1c79374df6..0c9a8a7a1c 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/knowledge-memory +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index b7da0f383d..772ff4f9f0 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.4.0", + "version": "17.5.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 9762e0d512..b1cfa03c28 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/knowledge-ragflow +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/service-knowledge@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index baebbba5ab..21c17bd790 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.4.0", + "version": "17.5.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 9a513a19a0..65fd474b12 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,91 @@ # @objectstack/organizations +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index a1aea863cd..f2f83b1bc0 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "17.4.0", + "version": "17.5.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 0d119e6ac3..7e793cd264 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,157 @@ # @objectstack/plugin-approvals +## 17.5.0 + +### Patch Changes + +- 4ef8247: fix(approvals): the dead-run sweep classifies every `ExecutionStatus` member, so a `refused` run releases its pending approval (#16433) + + `ApprovalService.releaseDeadRunRequests` guarded on a hand-copied four-member subset of `ExecutionStatus` — `completed`, `failed`, `cancelled`, `timed_out` — written when that enum had eight members. #14945 then appended `refused`, documented on the enum as *"Terminal, never resumed"*, and the subset did not grow with it. A run in `refused` was therefore skipped by the sweep, so a still-pending approval on it read as ALIVE, was never released, and kept its record lock forever. + + **Why this is shipped as a fix rather than left alone.** Nothing inside this repo drives a run to `refused` yet — that is #15788 (lane 2 of the #14945 ruling), still open. But `ApprovalService` takes a HOST-supplied automation surface through `attachAutomation`, so a host whose `getRun` already answers with the status the published spec declares sees the corrected behaviour the moment it upgrades, rather than on the day lane 2 lands. That is a real behaviour change in a published package, which is why it carries a bump instead of `skip-changeset`. + + The repair is not "add `refused`" — that yields a five-member hand-copy with the identical trap re-armed for the tenth member — and it is not "derive the terminal set from the enum" either, since `running` and `paused` are plainly not terminal and a wholesale derivation would default every future member to terminal, i.e. to releasing approvals out from under LIVE runs. Instead the file now declares a **total map** over `ExecutionStatus`, classifying each member `terminal` or `live`, from which the terminal set is derived. A tenth member fails to compile until someone classifies it, and fails a test as well. + + No API change: the classification is module-internal and the package barrel is untouched. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index b32ced2938..08c0d10d1e 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.4.0", + "version": "17.5.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 90bf92d823..c2d545c040 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/plugin-audit +## 17.5.0 + +### Patch Changes + +- ab48938: A lost audit row is reported once per failure CAUSE, not once per process, and the first line names the cause instead of a fixed remedy. + + `reportAuditWriteFailure` — the best-effort catch around `persistAuditTrailRow` — deduped on a single process-wide boolean. After the first failure of any cause, every later failure of every *other* cause degraded to `debug` for the life of the process, so a long-running server could keep losing compliance rows for hours to a second, unrelated fault with one `error` line at the top of the log describing the first. `persistAuditTrailRow` is registered in the durability-degradation vocabulary precisely because a lost audit row must be reported at `error`. + + The dedupe key is now the failure's identity — the error `code` (or its absence) together with the object being audited. A repeat of an already-reported cause still degrades to `debug`, exactly as before; a new cause reports at `error`, once. The key is built from the `code` and **never** the message: a driver names the offending row in its message, so a message-keyed dedupe would grow one `error` line per failed write. Keyed on the code, the reported-cause set is bounded by the boot-declared object registry and the driver's code vocabulary and does not grow with traffic — measured at 65 lines for 6,500 failed writes and the same 65 for 26,000. + + The first `error` line now leads with the underlying code and message, which were already computed at the call site and passed only into the `debug` payload. The ADR-0057 §3.6 telemetry-datasource guidance is kept — it is the correct remedy for the "no such table" cause it was written for — but is now printed only for that cause, decided by the shared `isMissingTableError` predicate for both tables this writer writes. Previously it was printed unconditionally, so an organization refusal was answered with "check the datasource", sending the operator to inspect something that was working. + + `@objectstack/types` is added as a dependency for that predicate, rather than hand-rolling a second driver-error vocabulary. +- 8d4690b: fix(plugin-audit): record-view rows keep the VIEW instant instead of the buffer-drain instant (#16829) + + `sys_audit_log`'s `record_views` rows answer "when did this user look at this record?". Read auditing batches its INSERTs off the request path by design, so `buildRow` writes `created_at: event.viewedAt` rather than letting the column's `NOW()` default stamp a whole batch with one flush timestamp — up to `flushIntervalMs` after the fact, with read order inside the window destroyed. + + `persistReadAuditRows` wrote that row under `{ context: { isSystem: true } }`, and the module's comment cited that flag as what carried the view instant through. It never was. `isSystem` exempts a write from the readonly strip; the layer that decides `created_at` on an insert is the audit stamp hook `sys_stamp_audit_insert`, which reads `session.preserveAudit` and has never read `isSystem`. What was actually carrying the value was that hook's pre-#15964 line, `record.created_at = record.created_at ?? now` — client-preferred on every insert, with no flag and no privilege required. #15964 closed that accident (maintainer ruling 2026-09-06), and the ordinary branch has stamped `now` since: on this path, the flush instant. + + The write now declares both context keys, for two different layers: + + ```ts + await engine.insert( + 'sys_audit_log', + rows as any, + { context: { isSystem: true, preserveAudit: true } } as any, + ); + ``` + + `isSystem` still carries the readonly-strip exemption the row needs; `preserveAudit` is the one the stamp hook reads. `preserveAudit` is the ruled historical-import channel (#3493, reaffirmed by #15964's ruling) — the door audit left open for reinstating an original timeline — and a view row's original timeline is the moment of the view, so this use is inside its declared purpose rather than a bypass of it. + + **What changes for a deployment.** Only for deployments that opted objects in to record-view auditing (`AuditPlugin`'s `readAudit.objects`). Rows written from now on carry the view instant. ⛔ Rows already written under the flattened behaviour are not repaired by this change: their `created_at` is the drain time of the batch they were in, and the view instant they should have carried was never persisted anywhere else, so it cannot be recovered. Only builds cut from `main` after #15964 are affected — the objectql half has not shipped in a published version. + + **No exported symbol, schema, route or config key moves.** The only observable change is that a `created_at` this writer already intended to write now survives. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 41a6242dd9..04040691d5 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.4.0", + "version": "17.5.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 774154c204..0c6cffe013 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,293 @@ # Changelog +## 17.5.0 + +### Minor Changes + +- 344d475: fix(plugin-auth)!: `POST /admin/create-user` reads the deployment's membership policy instead of hard-coding `auto` (#16683) + + **BREAKING** — the membership this published endpoint writes moves for existing inputs on `invite-only` deployments. The route, its request body, its response fields and every exported signature are byte-identical; what changes is what an existing call does on a deployment that declared a non-default policy, stated as a FROM/TO pair below. + + ADR-0093 D1 makes the deployment's `membershipPolicy` the one answer to "does this new account get an organization membership", and enumerates the `invite-only` flows as a closed set — "which endpoint created the user" is explicitly not a determinant. The `user.create.after` reconciler and the D6 backfill both read it through `AuthManager.getMembershipPolicy()`. This endpoint did not: its belt-and-suspenders bind handed the reconciler a literal `'auto'`, so it was the one membership-writing path in the product that ignored the setting. + + FROM: on a deployment declaring `membershipPolicy: 'invite-only'`, an account created through `POST /api/v1/auth/admin/create-user` was bound to the default organization anyway, and the 200 response answered `membershipCreated: true`. The `user.create.after` reconciler had already declined to bind it; this endpoint bound it afterwards. + + TO: the same call creates the account and binds no membership. The response answers `membershipCreated: false` and omits `organizationId`, and the audit row records the same. The account is created and can sign in — `invite-only` withholds the membership, not the login. + + Who is affected: only deployments that set `auth.membership_policy` (or `OS_AUTH_MEMBERSHIP_POLICY`) to `invite-only`. Under the default `auto` posture behaviour is unchanged in every observable respect — response body, `sys_member` write and audit metadata — and that equivalence is pinned by a test rather than asserted here. + + If you relied on admin-created accounts acquiring a membership on an `invite-only` deployment, the supported way to keep it is to bind the membership explicitly (the `add_member` action / `POST /organization/add-member`), which is what `invite-only` means: memberships are granted deliberately, never as a side effect of account creation. Setting the deployment back to `auto` restores the old behaviour for every path at once, including sign-up. + + The direction of the old defect was open, not closed: it GRANTED a membership the operator had configured the platform to withhold, and reported success while doing it. An operator who set `invite-only` specifically to keep a shared organization identity off their users got one anyway. + + +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- e758131: fix(plugin-auth): a `single`-posture deployment holding more than one organization is reported at `error` instead of booting silently (#17010) + + ADR-0131 §1.2(3) states that its precondition — many organizations with the organization wall inert — 「is today a refused boot」. It is not. A deployment that never REQUESTS a walled posture and simply HOLDS more than one `sys_organization` row under `single` boots, serves, and says nothing: `resolveDefaultOrgId` answers the bootstrap org, else the sole org when exactly one exists, else `null` — silently. The harm then surfaces far away and looks like an unrelated data outage: users reconciled from then on are bound to no organization, a platform admin reads zero rows of every organization-stamped object while analytics still counts them, and system-context writes are refused `ambiguous-organization` by the per-write guard. + + The tenancy service now takes a `count(sys_organization)` census on that same seam and reports at `error` when a non-walled deployment holds more than one, naming the posture it DECLARED, the count it HOLDS, and the two ways out: declare a walled posture (`OS_TENANCY_POSTURE=group` / `isolated`, plus the `@objectstack/organizations` package that activates it), or hold one organization and model the sub-units as business units. + + **The boot is not refused.** This change only reports; whether the boot should instead be refused stays open for the maintainer, and nothing here has to be undone if that is the answer. The per-write `ambiguous-organization` refusal is untouched. + + Cost is one `count()` per process: the census sits downstream of the walled-posture early return (a `group`/`isolated` deployment pays nothing and says nothing) and downstream of the memoized resolution, and an engine that cannot answer stays silent rather than guessing. A healthy install — exactly one organization, or none bootstrapped yet — is silent by construction. + +### Patch Changes + +- efa2533: fix(plugin-auth): build ONE better-auth instance per boot, so the RFC 8707 resource row is seeded once (#17176) + + `AuthManager.getOrCreateAuth()` assigned its `this.auth` memo only after `createAuthInstance()` had resolved, and that function awaits a dynamic `import('better-auth')`, the plugin list, the password hasher and finally better-auth's own `$context`. Every caller arriving inside that window read `this.auth === null` and started its own build, so overlapping callers constructed one better-auth instance each — measured: three concurrent `getAuthInstance()` calls returned three distinct instances. + + The boot has such callers. `AuthPlugin` dispatches `registerOidcDiscoveryRoutes()` with `void` from its route-mounting `kernel:ready` hook, which returns while that call is still pending, and a later `kernel:ready` hook reads the instantiated social providers off the instance for the account-issuer backfill. + + Each duplicate instance re-runs every better-auth plugin's `init`, and `@better-auth/oauth-provider` seeds the RFC 8707 `sys_oauth_resource` row from there. Its seed is already check-then-insert — `findOne` by `identifier`, then `create` only on a miss — so on a warm database every instance finds the row and inserts nothing. On a FRESH one all of them miss together, all of them insert, and the unique index refuses all but the first: the `Insert operation failed {object: sys_oauth_resource}` line on the first boot of a fresh project. + + `getOrCreateAuth()` now holds the in-flight build so concurrent callers share it. The seed runs once per process on every driver, because there is only one plugin `init` to run it. Two consequences of the new in-flight slot: `setRuntimeBaseUrl()` now reports "already created" for a build in flight (it silently no-opped before), and `applyConfigPatch()` discards a build composed from the pre-patch configuration instead of letting it install itself. + + No log level changed, in this package or any other. +- dd2fd20: fix(plugin-auth): one base-path normalisation chain, and an MCP resource identifier that is always a URL + + `AuthManager` derived its base path in three independent places. `getMcpResourceUrl()` + read `this.config.basePath` directly and added no leading slash, so a `basePath` + configured without one produced a value that is not a URL at all: + + basePath 'api/v1/auth' -> http://localhost:3000api/v1/mcp + + `new URL()` throws on that (`3000api` is not a port), so the RFC 9728 path-inserted + well-known route derived from it throws too, and `@better-auth/oauth-provider` 1.7.2 + refuses to seed the `sys_oauth_resource` row from it at plugin init ("resource + identifier ... must be an absolute URI (RFC 8707 §2)"). With + `enforcePerClientResources` at its `true` default, every MCP client was then refused + for want of a link row. That input class could never mint or match a token, so + repairing it re-selects nothing. + + There is now exactly one read of the configured value and one chain above it: + + configuredBasePath() the configured value VERBATIM — what better-auth is handed + └─ rootedBasePath() + a leading slash when absent (better-auth's own rule) + ├─ getAuthIssuer() = origin + this + └─ getBasePath() = this, trailing slashes stripped + └─ getMcpResourceUrl() = origin + this minus `/auth` + `/mcp` + + `getAuthIssuer()` and `getBasePath()` answer byte-identically to before for every + spelling. Only `getMcpResourceUrl()` moves, and only for a non-canonical `basePath`: + a missing leading slash (was not a URL), repeated trailing slashes, or a configured + `/` (was a `//mcp` path no mount serves). A canonical `basePath` is unchanged on all + three getters. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 96684bb: fix(plugin-auth): let `ImportProtocolLike` type the admin import protocol's members (#17422) + + `admin-import-users.ts` is the only hand-written in-repo implementor of the runner's `ImportProtocolLike`, and it annotated all three required members `args: any`. An explicit parameter annotation wins over the contextual type, so #16952's newly declared request dialect held every implementor except this one — the one with a demonstrated history: before #16950 this file read `args?.query?.$filter ?? {}`, the runner moved to the canonical spelling, the read went `undefined`, and the `?? {}` default degraded the import's duplicate probe into match-everything, so `POST /api/v1/auth/admin/import-users` updated the wrong users without a sound. + + The three annotations are deleted, so `findData` / `createData` / `updateData` are typed by the contract they implement. Measured: with the annotations gone, reading a retired wire alias (`args.query?.$filter`) is `TS2339 Property '$filter' does not exist on type 'QueryInput'`; with `args: any` restored the identical probe type-checks at exit 0. + + `FindDataRequest` declares `query` optional, so `findData` now states its refusal in code — a thrown `Error` carrying the already-registered `INVALID_REQUEST` code — instead of relying on an incidental `TypeError` from a property read on `undefined`. No `??` fallback and no optional chaining were added: both spell match-everything, which is the defect this closes. + + No API, request body, response shape or exported signature changes. A caller that reaches `findData` through `runImport` always supplies `query`, so no supported call moves; only a protocol call that was already failing now fails with a code attached. +- 45c2cf9: MCP OAuth: refuse a `client_credentials` (machine-to-machine) access token + + `AuthManager.verifyMcpAccessToken` resolved an M2M access token to a + principal — a machine ran as an authenticated member, stamping a user id that + belongs to no user into `created_by` / `updated_by` and owner columns — while + the method's own contract declared such tokens rejected. The contract's + premise was that they carry no `sub`; the OAuth provider stamps + `sub = user?.id ?? client.clientId`, so the premise was never true and the + rejection it described could never fire. + + The subject and the client identity are now read as a pair, the way RFC 9068 + defines them for a JWT access token: `client_id` is REQUIRED (§2.2), and `sub` + is the resource owner for a grant that had one or an identifier for the client + application for a grant that did not (§2.2.3.1). A token whose `sub` equals its + own `client_id` / `azp` therefore assembles no principal, and the MCP HTTP door + answers `401`. A token carrying neither client claim is refused as well: the + check has no input, and a check that cannot run must not silently pass. + + Unchanged: interactive OAuth clients (authorization code + PKCE) resolve + exactly as before, and the headless track is untouched — `x-api-key` / + `Bearer osk_…` over HTTP and `OS_MCP_STDIO_API_KEY` over stdio are a separate + chain with a separate credential shape, and remain the supported way for a + machine to call this platform. +- 9ca49eb: `runAdminImportUsers`'s hand-written `ImportProtocolLike` reads the CANONICAL QueryAST (`where` / `limit`) — the payload `@objectstack/rest`'s import runner sends as of this same release — instead of the wire-only `$filter` / `$top`. + + `POST /api/v1/auth/admin/import-users` reuses the shared import runner but swaps in an identity-specific protocol, because an identity write is `auth.api.createUser` and not an engine insert. That protocol is hand-written, so it never passes through `ObjectStackProtocolImplementation` — the normalizer that folds `$filter` onto `where` and `$top` onto `limit` for a caller arriving off the HTTP door. It has to read the canonical keys itself. + + - **A mismatch here does not produce a missing filter, it produces an unbounded one.** `const where = args?.query?.$filter ?? {}` turns an unread key into an empty filter, and an empty filter constrains nothing: the upsert duplicate probe stops discriminating, `findExisting` matches rows it was given no key for, and an admin import updates the WRONG user. Both halves are measured in `admin-import-users.test.ts` — the email-match case reported `updated: 2` where one of the two rows was new, and the phone-match case sent a probe carrying no `where` at all. + - **One dialect, and no default behind it.** The two reads are now `args.query.where` and `args.query.limit`, with no `??`. A default here would not be tolerance for an older caller — this handle is fed by the runner, never off the wire — it is precisely the lenient fallback that converts a spelling mismatch into a silent match-everything. A request that arrives without a `query` now costs a loud `TypeError` instead. + + ⚠️ No published version shipped the mismatch. The runner's rewrite and this adapter land in the same release, and `@objectstack/plugin-auth` depends on `@objectstack/rest` at an exact workspace version, so the two cannot be installed apart. What this entry records is why they move together — and what the same mismatch costs any OTHER hand-written `ImportProtocolLike`, which the `@objectstack/rest` entry calls out for implementors. +- ab1c585: `POST /two-factor/verify-totp` and `/two-factor/verify-otp` now echo the user row as it stands when the response is written, instead of the pre-rotation snapshot the vendor closes over. + + On the enrolment lane — a signed-in caller confirming a new factor — better-auth writes `twoFactorEnabled: true`, rotates the session, and only then calls the `valid(ctx)` closure it built at entry. That closure still holds the pre-rotation session, so a successful verification answered `user.twoFactorEnabled: false` to the very caller who had just switched 2FA on. An account portal reading that body renders the factor as still OFF right after enrolment, and a bearer client that caches the echoed user carries the wrong flag until its next `get-session`. + + `two-factor-rotated-token-echo` already repaired the body's other stale member, `token`, on exactly these routes and on exactly this predicate — the response staged a session cookie whose token differs from the one echoed. The `user` member is stale for the same reason, so it is repaired under the same predicate rather than a new one. + + - **Two narrowings, both load-bearing.** Only the members the vendor already echoed are written, so the published payload shape (`AuthWireUser`) cannot widen — better-auth's own output filter is a deny-list, and forwarding a raw row would put every column it happens to carry on the wire. And the row is re-read through `internalAdapter` by the id the response itself published, so the repair travels the same output transform that produced the echo (a driver that stores booleans as `1`/`0` cannot change a member's wire type) and can never substitute a different principal into a response. + - **`/two-factor/verify-backup-code` is untouched.** It does not rotate and already echoed the live row; it is in neither path list, its row is not read, and it is pinned as a negative control on both the in-memory engine and a real `SqlDriver` — an unconditional re-read would have "fixed" the broken lane and quietly rewritten one that was already right. + - **The failure posture is inherited.** A row read that throws or answers nothing degrades to the vendor's own echo, never to a failed verification and never to a lost `token` repair, which is written first for that reason. + + `@objectstack/client` drops the `AuthTwoFactorVerificationResult.user` warning that told callers to re-read the session for the live flag; the wire shape it declares is unchanged. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [690f083] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 4c15dce945..acd4b6ff18 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.4.0", + "version": "17.5.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 bd54f17513..768ecc3cd3 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,155 @@ # @objectstack/plugin-dev +## 17.5.0 + +### Patch Changes + +- 50bc9c7: Operator-facing text no longer tells an open-source install that multi-organization + operation requires a subscription. + + ADR-0132 moved the `org-scoping` registrar into open core — `@objectstack/organizations` + is Apache-2.0, carries no licence check, and declares both walled postures (`group` and + `isolated`) as its own constant. The messages an operator actually reads had not followed: + + - `os serve`'s install remedy for a walled posture ended "this runtime is closed-source and + is NOT on the public npm registry ... Without one this bullet is not followable" — it now + says the runtime is Apache-2.0 and on the public registry, and notes that a commercial + deployment resolves the same package name to its own private, licence-gated build. + - The `isolated` posture hint rendered by `os serve` and `os doctor` no longer calls the + runtime "enterprise". + - `os verify`'s `--org-scoped` flag description drops the same word. + - The dev stack's degraded-tenancy warning and its stage-2 mount refusal no longer describe + the package as the enterprise runtime. + + Text only — no control flow, no identifiers, no behaviour change. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [1a25f4a] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + - @objectstack/account@17.5.0 + - @objectstack/setup@17.5.0 + - @objectstack/service-i18n@17.5.0 + - @objectstack/service-realtime@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 4e9a8ac721..bcf30e3f00 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.4.0", + "version": "17.5.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 6a7fa513b6..dc5766aca2 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,95 @@ # @objectstack/plugin-email +## 17.5.0 + +### Patch Changes + +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 8ff131ba9c..8916bed793 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.4.0", + "version": "17.5.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 5afdbf01cb..a7e3d43fe6 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,156 @@ # @objectstack/plugin-hono-server +## 17.5.0 + +### Minor Changes + +- cefe068: fix(plugin-hono-server): an escaped throw that declares an ADR-0112 envelope is answered as that envelope, not as a bare `500 INTERNAL_ERROR "No response from handler"` (#16545) + + `HonoHttpServer.wrap()` is the seam **every direct-mount route passes** — `get` / + `post` / `put` / `delete` / `patch` each register `this.wrap(handler)`, and + `IHttpServer` is how `service-datasource`, `packages/rest` and the dispatcher + bridge all mount. Until now a throw that escaped a route handler was answered + there as `500 { code: 'INTERNAL_ERROR', message: 'No response from handler' }`, + with the thrown value discarded — so a producer that had *declared* its refusal + lost both halves of the declaration on the way to the caller. + + The measured case: `service-datasource`'s `requireDatasourceAdmin` re-raises + `AuthzStoreUnavailableError` (declared `status: 503`, declared `code: + SERVICE_UNAVAILABLE`) when the authorization store cannot be read — deliberately, + per the #13279 ruling that an unreadable store licenses no verdict. The operator's + outage reached the caller as a generic fault naming the wrong component: the + declared code never arrived, and the message said "No response from handler". + + **What changed.** An escaped throw carrying **both** a declared ADR-0112 status + (a key of `HttpStatusErrorCodeMap`) **and** a code registered in `ErrorCode` + (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) is now rendered as that envelope, + with the producer's `details` and `userMessage` channels forwarded. The status + and code are read through `resolveThrownHttpError` — the one rule the REST + registrar and the dispatcher already share — so this seam agrees with the other + doors by construction rather than by a second ladder. + + **What did NOT change**, pinned in the same PR: + + - an escaped throw that is **not** such an envelope answers exactly the bytes it + answered before — 500, no cause in the body. A partial declaration (status but + no code, code but no status), an unregistered code, and a status ADR-0112 does + not declare all take that arm; + - a handler that simply wrote nothing is untouched; + - a handler that **wrote and then threw** keeps what it wrote; + - the `notFound` fallback seam still answers `Fallback handler failed` — a + fallback that threw is a broken consumer, not a refusal it declared; + - ⛔ no error code is minted and no ledger row is added. A code on this path that + is not registered is a ledger gap under the #16404 ruling, and takes the + unchanged 500 arm rather than being registered in passing. + + The 5xx disclosure filter every door emitting a thrown message already runs + (`looksLikeInternalErrorLeak`, #3867 / #8086) is applied here from this seam's + first day: a driver dump on a declared 5xx is withheld, where the old bare 500 + disclosed nothing at all. The escaped-throw diagnosis (#5848) still fires exactly + once at `error`, and now names the answer that was really sent instead of + claiming an opaque 500. + + ⚠️ **Known-unreached door, stated rather than left silent.** A route mounted + through `getRawApp()` funnels through neither `wrap()` nor any registrar wrapper, + so it is **not** repaired by this change and still answers a non-envelope + `text/plain` 500. That is out of this card's scope by the `domain:cli` seat's + ruling and is filed separately. + +### Patch Changes + +- ca31ff6: Take the fix for the fifteen OSV advisories that turned `Validate Package Dependencies` red on every PR. + + The advisory database moved; the lockfile did not. `origin/main`'s `pnpm-lock.yaml` is byte-identical to the tree that scanned GREEN the day before and RED the day after, so this is a repo-wide condition rather than any PR's regression, and every one of the fifteen names a published fix version — the take-the-fix path `osv-scanner.toml`'s header describes, not the exemption path. That ledger keeps its zero entries and is untouched here, as is `.github/workflows/validate-deps.yml`. + + Two published packages change what a downstream install resolves, which is what this changeset grades: + + - **`@objectstack/plugin-email`** declares `nodemailer` `^9.1.1` (was `^9.0.5`), clearing GHSA-2x7j-588g-ccc2 (7.5), GHSA-cc9r-2j5m-2m83 (6.5), GHSA-wmmp-3585-3rmp (6.5) — all fixed in 9.1.0 — and GHSA-8m3c-c648-2xjj (5.9), fixed in 9.1.1. The range takes the higher of the two fix lines so one floor covers all four. The 10.x major is deliberately not taken. + - **`@objectstack/plugin-hono-server`** declares `hono` `^4.13.5` (was `^4.13.2`), clearing GHSA-crvj-82cr-hjcx (5.9), GHSA-g6gw-c38x-mqfc (5.3) and GHSA-gqvv-2mrq-wpjv (6.5). + + No exported symbol, payload key or accept/reject behaviour of ours moves — the published surface is unchanged and both grade `patch`. + + The rest of the sweep releases nothing and is named here only so the set is readable in one place: the `sharp` override target lifts to `^0.35.4` (GHSA-rgj7-g3m4-5g8c, 8.9) and the `hono` override target to `^4.13.5`, both target-only lifts whose selectors already sit at the compatibility boundary; the private docs app takes `next` 16.3.3 (GHSA-2xp9-vwfh-vxw4 9.5 and GHSA-p293-qw3h-jr36 9.0, the two Criticals); and the `vitest` devDependency line takes 4.1.11 across the workspace, with `@vitest/coverage-v8` moved in lockstep because its peer on `vitest` is exact (GHSA-82fw-gwwq-j7x9, 5.9, which flagged both `vitest` and `@vitest/mocker`). + + `hono` was flagged at TWO resolved versions and both are gone: the override lift is what collapses them. The transitive copy `@modelcontextprotocol/sdk` pulled sat exactly on the old `^4.12.34` floor and so was never re-resolved, while our own three declarations floated up to 4.13.2; `^4.13.5` excludes the floor, both edges re-resolve, and the tree now holds one `hono`. A bump that moved only our declarations would have left the transitive copy flagged and the gate red. +- 0ced0aa: **`getRawApp()` mounts now answer an escaped throw with the declared ADR-0112 envelope.** A route mounted on the Hono handle funnels through neither the adapter's `wrap()` nor any registrar wrapper, so an escaped throw was answered by Hono's own default handler — `500 text/plain "Internal Server Error"`, no `success` flag, no `code`, and the thrown value's own declared `status` / `code` discarded. A transport error seam on the raw handle now renders the same throw-to-envelope rule a direct-mount route already used, so both doors answer one shape: a throw declaring `503` / `SERVICE_UNAVAILABLE` answers `503 application/json` with `{"success":false,"error":{"code":"SERVICE_UNAVAILABLE",…}}`, and a throw declaring no envelope still answers `500` with no cause in the body. + + The escape hatch is unchanged: consumers still mount framework-natively, still stay outside `getMountedRoutes()`, and still need no adapter verb. A thrown value carrying its own `Response` (Hono's `HTTPException`) keeps the response it declared. A consumer that installs its own `getRawApp().onError(...)` replaces the seam. + + Also fixed alongside it: `afterResponse` observers — and therefore `http_requests_total{status}` — reported a hard-coded `500` for any request that ended in a throw, which stops being the status actually sent once a declared envelope is rendered. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 9f0383e009..d43108da7e 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.4.0", + "version": "17.5.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 5535204f49..7c8ad16173 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,36 @@ # @objectstack/plugin-pinyin-search +## 17.5.0 + +### Patch Changes + +- Updated dependencies [4c42fd1] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [2bed4c3] +- Updated dependencies [71629a1] +- Updated dependencies [d2c1d19] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [0f38ab0] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5a95b0e] +- Updated dependencies [07150b3] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [8c9bd8f] + - @objectstack/core@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a925269ae8..90f6a78e4c 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.4.0", + "version": "17.5.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-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md index ba4c503ddc..be8e2011df 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/plugin-reports +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 184649861d..599f0a8cd0 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 02025fa814..b4ace7df1a 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,347 @@ # @objectstack/plugin-security +## 17.5.0 + +### Minor Changes + +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- a016f08: fix(plugin-security)!: the insert-side RLS `check` is evaluated on the row that will be STORED — after `beforeInsert` — instead of on the caller's raw payload (#16608) + + + + **BREAKING** — an accept-set narrowing on the write gate's refusal behaviour. An insert that is admitted today can be refused after this change. + + `check` validates the row a write produces — the PostgreSQL `WITH CHECK` analog. `update` reached that row by merging the caller's pre-image with the change set. `insert` could not: it has no pre-image, and the security middleware runs BEFORE the engine's operation, so its post-image was `opCtx.data` — the caller's payload as it arrived, ahead of `applyFieldDefaults` and ahead of every `beforeInsert` hook. + + A denormalised scoping field is exactly what an RLS predicate compares (ADR-0055: a predicate cannot traverse a lookup) and exactly what an app stamps server-side so a caller cannot choose it. Judging the raw payload therefore inverted the policy in both directions, measured on 17.3.0 with a real engine, a real `SecurityPlugin` and both drivers: + + - **the derived value was not on the image**, so the only way to pass a `check` over it was for the caller to SEND the value the hook exists to make un-sendable. Same identity, same object, same second: the payload carrying the stamped field returned 201, the identical payload leaving it to the hook returned 403 — and the stored row was identical either way. + - **the sent value WAS on the image and was then overwritten**, so an insert naming an in-scope organization while pointing at a parent in ANOTHER organization PASSED the check and stored the parent's organization. That is a row whose stored scope the caller does not hold, and it is why this is a narrowing rather than a widening: today it is admitted, after this change it is refused with nothing stored. + + Ruled 2026-09-07 (maintainer, verbatim 「同意」, director seat, summon #17, decision batch #3). The refused alternative — keep the order and write the contract that a checked field must arrive from the caller, plus an `os validate` rule to police it — institutionalises the contradiction and needs a permanent lint to hold it in place. + + **What changed, mechanically.** `OperationContext` gains `postHookWriteImageCheck` (`@objectstack/objectql`), an optional judgement an enforcement layer installs and `ObjectQL.insert` runs once the `beforeInsert` chain has produced the row — after the post-hook declared-field door, after the two value-changing strips (`stripRuntimeOwnedFields` and the static-`readonly` strip with its `defaultValue` re-default, both moved ahead of it), and before every producer with a side effect (the secret channel, the autonumber, validation, the statement), so a refusal still costs nothing. `@objectstack/plugin-security` installs its compiled `check` filter there for `insert` instead of matching it against `opCtx.data`; `update` is unchanged. The compiled filter is still built in the middleware, where the caller's permission sets, the ADR-0090 D10 delegator's, the staged membership and the request context are all resolved — only the IMAGE is deferred. A middleware that installed the judgement and finds the seam was never run refuses the write and logs at ERROR: an unjudged write is not an allowed one. + + **Who is affected.** Only objects governed by a permission set that EXPLICITLY declares `check`, on single-row inserts by a non-system caller — the gate's existing scope, unchanged. Two behaviour changes to expect, and they are the two halves of the same correction: an insert that left a hook-stamped field off the payload now succeeds where it used to be refused, and an insert whose hook-stamped field lands outside the caller's scope is now refused where it used to be admitted. Callers that were duplicating the stamp to get past the gate keep working and may stop. + + **Two further behaviour changes the reorder produces, measured on both legs** (the reviewed order and this one), because moving the strips ahead of the seam also moves them ahead of the credential channel: + + - a caller-forged value on an author-declared `readonly` **`secret`** field is now stripped. Before, `encryptSecretFields` ran first and replaced the row's value with a `sys_secret` reference, so the strip's `Object.is` value test compared that reference against the caller's plaintext, read the difference as a hook's write, and KEPT the forgery — measured on 17.3.0's order as stored `token: "secret:sec_1"` with a `sys_secret` row minted. This is a narrowing, and it closes a hole that predates this card. + - an empty string on a `readonly` **`password`** field is stripped instead of answering `VALIDATION_ERROR`. `""` reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee is unchanged; only which refusal a caller sees moves, on a payload a caller was never allowed to send. ⚠️ This is the one direction of the reorder that is not a narrowing, and it is recorded rather than left to be discovered. + + **The invariant this buys, stated to its real edge.** A stored row satisfies the insert `check` on every field the CALLER can steer, whatever the caller sent. Nothing offered any such guarantee before: the check read the payload, and the payload was entirely the caller's. + + ⚠️ It is deliberately not "on every field", and the difference is a boundary rather than a hedge. Four engine-owned passes still run between the judgement and the driver, and each substitutes a platform value for whatever stands on the row: the tenant fill of an ABSENT organization column (`resolveSystemInsertOrganization` plus the driver's `injectTenantOnInsert`), `encryptSecretFields` replacing a `secret` field's plaintext with a `sys_secret` reference, `applyAutonumbers` issuing a record number, and `normalizeMultiValueFields` coercing a declared multi-value field to its stored shape. A policy whose `check` names an autonumber, a `secret` or the tenant column is therefore judging a value the platform is about to replace. None of those four is caller-steerable — which is exactly why the two passes that WERE (`stripRuntimeOwnedFields` and the static-`readonly` strip) moved above the seam instead of being explained away. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 1c83ca2: The first-boot `already_have_admin` short-circuit now FINDS an existing platform admin instead of sampling for one, so a tenant's organization-admin count can no longer decide whether a second unscoped `admin_full_access` grant is minted. + + Before this change the holders read was `sys_user_permission_set` with **no `orderBy` and a cap of 50**, and the predicate that actually decides — `!organization_id` — was applied **client-side to whatever 50 rows the driver returned first**. `admin_full_access` is not only the platform-admin set: every *organization-scoped* grant of it writes a row carrying the same `permission_set_id`, so this population grows with the number of **org** admins, not platform admins. A tenant with fifty-odd of them filled the window with rows that all fail the filter, the short-circuit did not fire, a **second** unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a genuinely fresh install. Measured on the real better-sqlite3 driver: with 60 organization-scoped grants plus one unscoped human grant, the unordered 50-row window contained 50 organization-scoped rows and not the one that decides. + + That is the guarantee #14348 case D pins — 「Moving an already-granted platform admin is reserved to the maintainer.」 — failing open by row count. + + - **The read asks the driver the narrow question first.** `{ permission_set_id, organization_id: null }`, ordered and bounded. Because it is narrowed server-side, no number of organization-scoped grants can crowd the answer out of a window. + - **A second, ordered and bounded leg still applies the exact predicate.** It runs only when the narrow leg found nobody. This is deliberate rather than redundant: `organization_id: ''` is storable and reads back as `''` on both SQL families, which `!organization_id` counts as **unscoped** and `where: { organization_id: null }` does **not** return — so replacing the client-side predicate with the narrowed read alone would have made this guard fire *less* often and mint the very grant this fixes. Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less. + - **The bound is never silent.** The scan pages 200 rows at a time up to a 5000-row ceiling, and reaching that ceiling without finding an unscoped human holder now WARNS — naming the ceiling, the number of rows examined, and the consequence (promoting from here would mint a second unscoped grant and re-own the seeded records). + - **The answer says how many rows it examined.** `bootstrapPlatformAdmin`'s returned report gains an optional `adminGrantRowsExamined`, counted by row identity across both legs, on every return the guard reaches. A guard that had seen the whole population and one that had seen a truncated slice of it previously returned byte-identical payloads. + - **The ordering is stated to the driver, and it is measured, not assumed.** `tryFind` answers `[]` when a query is refused, and on this guard `[]` reads as "no platform admin exists yet" — which promotes. An order this object could not serve would therefore be a silent relaxation, so `id` ascending was measured honoured through ObjectQL on both SQL driver families against the real declarations. + + Unchanged: an unscoped grant held by the seed identity `usr_system` still never counts, so a database where it was wrongly promoted stays self-healing on restart; the walled postures still mint no grant row and still point a legacy unscoped holder at the config path; and a genuinely fresh install still promotes exactly as before. +- 9b9581b: First-boot platform-admin promotion under the `single` posture now CHOOSES its target instead of sampling one: the candidate read is ordered by the database, and an operator who declared an owner gets that owner — and only once that owner has verified the address. + + Before this change the selection read `sys_user` with **no `orderBy` and a cap of 50** and then sorted that array client-side, so "the oldest authenticable user" actually meant *the oldest authenticable user among whatever 50 rows the driver produced first*. Measured on 113 seeded users with the intended owner inserted first, holding the oldest `created_at` and an id that collates last: the in-memory driver returned it in row 1 and promoted it, while the default sqlite driver returned rows in id order, never saw it at all, and handed the unscoped `admin_full_access` grant — plus, through `claimSeedOwnership`, ownership of every seeded business record — to a seeded job-seeker persona. Same code, same config, same data; the answer changed with the storage driver. + + - **The read is ordered where the driver can see it.** `created_at` ascending with `id` as the tie-breaker (seeded populations routinely share one timestamp). There is deliberately no client-side re-sort left behind: one would re-rank the returned page and keep the guard passing if the ordering were ever lost again. + - **The declared owner is asked first, and must be a VERIFIED holder.** `OS_PLATFORM_OWNER_EMAIL` was imported into this file and read only on the walled branch, so a deployment that had said who its owner is could still have someone else promoted. Under `single` the target is now a row that holds a declared address, is human, can authenticate, and has `email_verified === true` — all four. Requiring verification rather than merely preferring it answers the one direction in which honouring the declaration would otherwise have been a widening: because `sys_user.email` is UNIQUE on the SQL family, an attacker who registers the declared address before the operator does would have been promoted with no way for the real owner to coexist, so an unverified holder is refused instead. + - **A declared owner who cannot sign in, or has not verified, REFUSES.** No silent fall-back to whoever happens to be oldest — that is the outcome this fixes. The pass warns, naming the variable, the address and which of the two is missing (`declared_owner_not_authenticable` / `declared_owner_not_verified`), and promotes nobody. **Accepted cost, stated rather than discovered:** a `single` deployment whose declared owner has not verified their email gets no platform admin at first boot until they do, loudly. Because the pass replays per sign-up while no admin exists, that warning re-emits on each replay until the owner is promotable; it is deliberately not latched, so the condition stays visible in the log a fresh operator is actually reading. + - **Verification landing is a replay trigger again.** `shouldReplayBootstrapFor` admits a `sys_user` update touching `email` / `email_verified` under `single` — but only while an owner is declared, which is the only configuration where such a write can change the answer. With none declared, the trigger set stays exactly as narrow as it was. + - **The cap is replaced, and never silent again.** A 200-row page with a 5000-row scan ceiling, walked oldest-first. Because the page is ordered it holds the rows the age rule actually wants, so truncation can only bite when every one of the oldest 5000 humans is non-authenticable — and reaching the ceiling now WARNS, naming the number examined. + - **The grant's log line records WHY and FROM HOW MANY.** `[security] first user promoted to platform admin: ` keeps its prefix and gains the basis (`declared-owner` / `oldest-authenticable`) and the candidate-pool size, repeated as `basis` / `candidatePoolSize` fields for structured sinks. The returned report carries `basis` too. + + Unchanged: no declaration still means first-user promotion by age (`single` keeps Choice 4A), and that leg has no verification requirement; a user nobody can authenticate as is still never promoted; an existing unscoped grant still short-circuits before any selection runs, so no deployment that already has an administrator can be re-pointed by this. +- 2a79726: feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) + + `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. + + Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). + + Two doors, two shapes, for a reason: + + - **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. + - **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. + + Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. + + Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. +- 7026141: fix(plugin-security)!: an RLS predicate naming an undeclared column now denies in EVERY position and polarity, on the read face and the write face alike (#17042) + + + + **BREAKING** — a fail-open-to-fail-closed narrowing on row-level security. A policy that widened yesterday denies today. Shipped as `minor` under the launch-window convention, the same grading the insert-side `check` post-image narrowing used. + + A predicate naming a column the object does **not declare** could not narrow, and in a **negation-carrying position** it did not deny either — it **widened** the policy to every row inside the tenant wall, and on the write path it **permitted** the write the policy was authored to refuse. + + ⛔ It is **not** a cross-tenant leak. Tenancy is a separate layer and it holds. What was defeated is the narrowing the policy author wrote *inside* the wall — an owner-only or private-record policy silently becoming "every row". + + Two independent sites, each with its own reason, each measured against the same two controls (a real column must still narrow; the *same* phantom column in a **positive** position must still refuse): + + - **Read face.** `extractTargetField` is a **leading-only** `==` / `=` / `in` shape match, so `nope != "x"`, `!(nope == 1)`, `!(nope in ['a'])` and any arm after the first returned `null`; the policy was **kept**, the drop counter never incremented and the deny sentinel never armed. The kept filter then met the settled include-direction ruling — a row that *has* no such column satisfies "column != x". Measured on the matcher: **3 of 3** rows for each negated shape, against **1 of 3** for the real narrowing and **0 of 3** for the same phantom column in a positive position. + - **Write face — the worse one.** `computeWriteCheckFilter` compiled `check` clauses with **no field-existence check at all**, and the ADR-0058 D4 post-image gate evaluates that filter in-process. Measured end to end on both SQL drivers: every negated phantom **permitted** the insert, in both post-image polarities, while a positive phantom refused (by accident of an absent value comparing unequal) — which is why a suite that only ever exercised the positive shape stayed green over the hole. + + **The repair is one seam, not two.** `RLSCompiler.compileFilter` — the single choke point both the read layer and the write gate already pass through — now takes the object's declared-column set and judges every column the policy names on the **compiled** `FilterCondition` tree. That is positional-agnostic by construction: the pushdown compiler lowers `!` to `$not`, `||` to `$or` and `&&` to `$and`, so a column lands as a plain object key whatever position it was authored in, and there is no spelling of negation left for a shape match to miss. Widening the regex instead was rejected: a matcher that must enumerate every spelling of negation is the same "recognises only what it was told about" defect one level over, and it would additionally have broken the ADR-0095 carve-out that *depends* on the regex recognising only the leading shape. A policy dropped this way joins the existing fail-closed path — same deny sentinel, same WARN line — rather than growing a parallel mechanism. + + ⛔ **The matcher's include-direction ruling is untouched.** A row lacking a column *does* satisfy "column != x" for an ordinary user query, and re-semanticing every filter in the repo to fix one caller is not the trade. The defect was that a policy compiler lowered an undeclared column into a filter at all; the matcher now never sees a phantom, and a regression test pins the raw matcher still answering 3 of 3 for the same filter so a later reader can see which half moved. + + **Who is affected.** Only a permission set carrying an RLS policy whose predicate names a column its object does not declare — an authoring mistake `@objectstack/lint` already reports on all of these shapes. For such a policy the object now returns **zero rows** for every holder of the set (read) and refuses every governed insert / update (write), where before a negated spelling returned everything and permitted everything. ⚠️ **An installation relying on such a policy to grant access will lose that access at the upgrade, and that is the intended direction**: what it was "granting" was the absence of enforcement. Correct the column name; the linter names the miss and offers the object's real field list. + + **driver-sql, previously unmeasured, is now measured, and it refines the picture.** On the **read** face `driver-sql` and `driver-sqlite-wasm` never widened — they failed closed by **raising** `INVALID_FILTER` / 400 when the phantom column reached the statement builder, so the read-face defect was driver-dependent (in-process matchers widened; SQL raised). On the **write** face they failed open exactly like every other driver, because the `check` is evaluated in-process and never reaches SQL. After this change both faces answer uniformly on both drivers. `driver-mongodb` remains inferred from the shared ruling rather than measured. + + `@objectstack/lint`'s diagnostic for this miss is corrected in the same change. Its **detection is unchanged** — all the negated shapes were already reported. Its consequence text was stale in one half and misattributed in the other: it described the field miss as having two directions decided by position, and it credited the write leg's fail-closed to a safety net that path never had. It now states one direction for both clauses, and records the older runtime's fail-open write behaviour explicitly so an operator reading it against a deployment that predates this guard is not told the wrong thing. + +### Patch Changes + +- fb7d75f: Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840). + + `reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again. + + `normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds. + + ⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. + + No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves. +- 470746a: fix(security): resolve `current_user.accessible_org_ids` into the RLS variable bag (#16518) + + `patch` — a bug fix in a released package. No API signature changes, no exported + symbol added, no spec or ADR edit: the contract already promised this, and only + the line that delivers it was missing. + + ## What was wrong + + `packages/spec/src/contracts/rls-membership-resolver.ts` does not merely reserve + the name `accessible_org_ids`. It declares the field's SHAPE (`:53`, + `accessible_org_ids?: string[]`), states at `:35` that the key is CORE-resolved + and not an app resolver, and lists it at `:70` in + `RESERVED_RLS_MEMBERSHIP_KEYS` — so an app's membership resolver is refused when + it tries to supply the set itself. `ExecutionContext.accessible_org_ids` goes + further and names the RLS spelling outright: *"RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`"*. + + `RLSUserContext` declared `id`, `organization_id`, `positions`, `org_user_ids` + and `email`, and nothing copied `accessible_org_ids` out of the execution + context. So the key was reserved on the grounds that core resolves it, and core + did not resolve it — a slot with a declared shape and no filler, which is the + ADR-0049 "declared but unenforced" shape. + + **The cost is the invisible one.** A predicate such as + `employer_org IN (current_user.accessible_org_ids)` compiled to an unresolved + variable, every applicable policy dropped out, and `RLS_DENY_FILTER` returned + **zero rows with no error raised**. Nothing failed. An empty list is + indistinguishable from "this user really has no data", which is how the shape + survived three green static gates and, in the reporting app, left ten policies + across six objects inert — the entire multi-tenant isolation model. + + The failure direction is **closed**: zero rows, never a cross-tenant read. This + is a usability and declared-means-enforced defect on a security surface, not a + leak. + + ## What it does now + + `RLSCompiler.compileFilter` copies `ExecutionContext.accessible_org_ids` into + `RLSUserContext`, following `org_user_ids`' precedent exactly — both are + core-resolved membership sets the runtime **pre-resolves**, precisely so this + compiler never has to issue a subquery. The compiler is unchanged otherwise; it + already handled the value correctly once present. + + The producer already existed and is unconditional: `resolve-authz-context.ts` + types the set as required and `assemble-execution-context.ts` copies it on every + face, in every posture (*"in `single` posture the set is resolved but no wall + consumes it"*). Only the consuming line was missing. + + One consequence worth naming: **reserved now means reserved at the compiler + too.** `stageRlsMembership` screens reserved keys out of a *resolver's* answer, + but a bag already present on the context was spread through unscreened, and + landed in the variable bag because nothing named the field. Now that the kernel + names it, the compiler's own "a membership key never clobbers a named field" + rule covers it and the kernel's value wins. + + ## Measured, end to end + + A rig on real drivers (`driver-sql`, `driver-sqlite-wasm`), six rows across + three organizations, a caller holding membership in two of them: + + | predicate | before | after | + |:--|--:|--:| + | `employer_org IN (current_user.accessible_org_ids)` | **0 of 6** | **4 of 6** — the rows of both orgs | + | same, caller scoped to ONE org | 0 of 6 | 2 of 6 — that org only | + | same, caller with no set / an empty set / an org with no rows | 0 of 6 | 0 of 6 — unchanged, still fails closed | + | a predicate naming a NON-EXISTENT variable | 0 of 6 | 0 of 6 — unchanged (#16119's face, untouched) | + | `org_user_ids`, `organization_id`, `email`, `id`, an app membership key | — | byte-identical | + + An app **could** work around the defect by supplying the same set under its own + unreserved key through `rlsMembership` and rewriting its predicates to + `current_user.my_org_ids`; that reads 4 of 6 on the same rig, before and after. + The workaround costs every app a membership-resolver registration it should not + need and moves every predicate off the documented spelling — and it is no longer + necessary. +- ac24458: security(rls): the RLS compiler refuses `RESERVED_RLS_MEMBERSHIP_KEYS` by name + + A caller-supplied `ExecutionContext.rlsMembership` entry could supply a RESERVED + kernel key — `id`, `organization_id`, `positions`, `org_user_ids`, + `accessible_org_ids`, `email` — whenever the kernel had not resolved a value for + that key on the request. `RLSCompiler.compileFilter` admitted a membership key on + the test `userCtx[key] === undefined` ("did the kernel happen to resolve one"), + not on whether the key is reserved, so an absent kernel value handed the name to + the bag. + + The direction was widening. With the key unresolved, the predicate referencing it + fails CLOSED — it joins the dropped-policy path and the compile returns the deny + sentinel, which yields zero rows. The bag instead produced a satisfiable filter + over caller-chosen values, converting a denial into a match. + + The merge now refuses reserved keys by name, at the one seam both faces pass + through (the read layer compiles `using` there, the ADR-0058 D4 write gate + compiles `check` there). `stageRlsMembership`'s existing screen covers only the + registered resolver's answer, and only when a resolver is registered at all — it + returns at its first line otherwise — so it could not carry this guarantee. + + No behaviour change for non-reserved membership keys, and none when the kernel + did resolve the reserved value: the kernel's value already won, and still does. + A refused key simply stays unresolved, so its policies drop out and fail closed + through the reason vocabulary that already exists. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 18dbc3dff6..6f96c6ab22 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.4.0", + "version": "17.5.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 bcfab501d6..3f5da5a61b 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,148 @@ # @objectstack/plugin-sharing +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2e41707313..5a70180667 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.4.0", + "version": "17.5.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 5b6784db0f..70a1f53329 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/plugin-webhooks +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [690f083] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index cad3e3500f..e008c76a68 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.4.0", + "version": "17.5.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 adfe5dcf6f..44e2f17121 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/dogfood +## 0.0.45 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [e526556] +- Updated dependencies [f19dbcf] +- Updated dependencies [86c5052] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [690f083] +- Updated dependencies [a9096af] +- Updated dependencies [4be4e04] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [4ef8247] +- Updated dependencies [ab48938] +- Updated dependencies [efa2533] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3977410] +- Updated dependencies [46cf705] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [cca1dc0] +- Updated dependencies [9540590] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [8d4690b] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [6ff5b56] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [6058cb2] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/mcp@17.5.0 + - @objectstack/service-messaging@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/verify@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/plugin-approvals@17.5.0 + - @objectstack/plugin-audit@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-storage@17.5.0 + - @objectstack/plugin-email@17.5.0 + - @objectstack/trigger-schedule@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/example-crm@4.0.97 + - @objectstack/example-multi-package@0.0.4 + - @objectstack/example-showcase@0.3.19 + - @objectstack/connector-mcp@17.5.0 + - @objectstack/connector-openapi@17.5.0 + - @objectstack/connector-rest@17.5.0 + - @objectstack/plugin-webhooks@17.5.0 + - @objectstack/trigger-record-change@17.5.0 + ## 0.0.44 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 5e6059d64e..bd27c30801 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.44", + "version": "0.0.45", "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 5f44fedc68..b9ffc38814 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,72 @@ # @objectstack/downstream-contract +## 0.0.43 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 0.0.42 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index f2fa9a2790..90d63d3e4d 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.42", + "version": "0.0.43", "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 38eb98f273..0b31f14550 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,17 @@ # @objectstack/http-conformance +## 0.1.5 + +### Patch Changes + +- Updated dependencies [4c42fd1] +- Updated dependencies [0da638c] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [71629a1] +- Updated dependencies [07150b3] + - @objectstack/core@17.5.0 + ## 0.1.4 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 311de0bb09..4c77e71426 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.4", + "version": "0.1.5", "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 46c756f426..babd802f6e 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,390 @@ # @objectstack/rest +## 17.5.0 + +### Minor Changes + +- 94c9302: `POST /analytics/dataset/query` parses its `selection` at the door, the way its two siblings already do + + The route checked one thing about the body it forwards — that + `selection.measures` was a non-empty array — and forwarded everything else + unexamined. `/analytics/query` and `/analytics/sql` Zod-parse their body at + the entry and lift a malformed member to a 400 before the service is reached, + so a client met two postures on one family depending on which door it knocked + on, and a malformed member of `selection` travelled into `dataset-executor` to + be answered by whatever the face behind it happened to do with it. + + ⚠️ **A 400 is newly reachable.** Requests that previously slipped through are + now refused. Two shapes: + + - A `timeDimensions[].dateRange` outside the closed preset vocabulary answers + `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` — the same code, status and wording + the sibling door has answered for the identical condition since the + vocabulary closed. Measured on the tree before this change, the literal + string `not a range at all` reached the executor under an ordinary `200`. + - Anything else malformed answers `400 VALIDATION_FAILED` with + `details.fields[]`, each entry naming the member as `selection.`. + + **What is NOT newly refused, deliberately.** `selection` is a + `DatasetSelection`, which is *not* the `AnalyticsQuery` the siblings parse: it + carries no `cube`, and `runtimeFilter`, `dateGranularity`, `compareTo` and + `totals` are members of its own. Reusing the sibling schema would have refused + every real dashboard widget. What the door parses is the projection of the + seven members whose declaration on `DatasetSelection` *is* the `AnalyticsQuery` + member of the same name — `dimensions`, `measures`, `timeDimensions` (declared + there by reference), `order`, `limit`, `offset`, `timezone` — so the refusal + set is exactly what the published interface already declared. The four + dataset-only members are projected away before the parse and keep reaching the + executor untouched. + + Validation-only: the caller's `selection` object is what `queryDataset` + receives, by identity, never a parse output. +- ab56ea3: refactor(rest)!: `ImportProtocolLike` declares the request each of its three required members receives, instead of `args: any` (#16952) + + The exported extension point `runImport` accepts a protocol through now states its own contract. + + **FROM** — every required member erased its parameter, so the interface declared nothing about the request it would hand an implementor: + + ```ts + export interface ImportProtocolLike { + findData(args: any): Promise; + createData(args: any): Promise; + updateData(args: any): Promise; + } + ``` + + **TO** — each member names the declared spec request, wrapped in the server-scoped envelope the runner adds (`ImportProtocolRequest`, exported alongside): + + ```ts + export type ImportProtocolRequest = R & { context?: any; environmentId?: string }; + + export interface ImportProtocolLike { + findData(args: ImportProtocolRequest): Promise; + createData(args: ImportProtocolRequest): Promise; + updateData(args: ImportProtocolRequest): Promise; + } + ``` + + **Why this is breaking-ish, and released as `minor`.** This is a narrowing of a published surface: an implementor that compiles today may stop compiling. Nothing about the values the runner sends changes — the request objects are byte-for-byte the ones #16638 already made canonical — so no runtime behaviour moves. What changes is that the compiler now holds an implementor to the same `QuerySchema` the runner is held to: `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` are declared, and the wire spellings `$filter` / `$top` are not. + + **Migration for implementors.** If your `findData` / `createData` / `updateData` reads a wire alias, it will now fail to compile — that diagnostic is the point of this change, and the fix is to read the canonical key: + + ```ts + // before — compiles, and silently degrades to match-everything when `$filter` is absent + async findData(args: any) { + const where = args?.query?.$filter ?? {}; + const limit = args?.query?.$top ?? 2; + } + + // after — drop your own annotation and let the declaration type the parameter + async findData(args) { + const where = args.query!.where; + const limit = args.query!.limit; + } + ``` + + ⛔ An implementor that keeps an explicit `args: any` annotation of its own opts back out: the annotation wins over the contextual type, and the contract reaches nothing. Leave the parameter unannotated, or name `ImportProtocolRequest` explicitly. + + ⚠️ The `?? {}` shape in the "before" is the mechanism that made a dialect mismatch silent rather than loud: an unrecognised query does not throw, it degrades into a filter that constrains nothing, so a duplicate probe stops discriminating and an upsert updates the wrong record. Prefer a read that throws. + + +- 9ca49eb: `import-runner.ts` builds its three server-side `findData` requests in the CANONICAL QueryAST, and the helper that carried them is typed against the declared contract instead of `any`. + + `FindDataRequestSchema` declares `query: QuerySchema.optional()`, and `QuerySchema` declares `where` / `limit` / `offset` / `fields` / `orderBy` / `expand` — it declares neither `$filter` nor `$top`. The normalizer's own table calls those two "the wire-only spellings no schema declares". The reference resolver, the duplicate probe and the id recheck each built a literal in that undeclared dialect, and nothing reddened because the helper they went through took `query: any`: the literals were type-checked by nothing at all, so the undeclared keys cost no diagnostic. Reverting one of them to `$filter` now costs `TS2353 … '$filter' does not exist in type 'QueryInput'`; on the pre-change file the identical revert cost zero errors. + + - **The three literals.** `$filter` → `where`, `$top` → `limit`, plus the `object` the declared query requires. No behaviour change on the two `rest-server.ts` call paths (`POST /data/:object/import` and the async import-job worker), which hand `runImport` the real `ObjectStackProtocolImplementation`: that normalizer folds `$filter` onto `where` and `$top` onto `limit` by the spec's own `RPC_QUERY_ALIAS_SLOTS`, moving the value verbatim, so both dialects reach `engine.find` as the same option bag. + - **The erasure vehicle.** `findArgsBase` now takes a `FindDataRequest` rather than a bare `any` query, so the request-level `object` is compiled too and the `object: ''` placeholder every caller had to override is gone. This is the durable half: rewriting the literals while leaving the parameter `any` would leave the next author in this file with no diagnostic at all. + - **The pin.** `rest-server-canonical-query-ast.test.ts` censuses the PACKAGE rather than one file. `import-runner.ts` has no HTTP door — every query in it is server-built — so its census rejects a wire spelling anywhere in the file, not only inside a `query:` slot. That whole-file rule is the one that finds this class: these three literals were arguments to a helper and were never in a `query:` slot to begin with. + + ⚠️ Implementor-visible: `ImportProtocolLike` is exported, its `findData(args: any)` never declared which dialect the runner sends, and the runner now sends the canonical one. An implementation that reads `args.query.$filter` / `args.query.$top` directly — rather than through the protocol normalizer — receives `undefined` and must be updated to read `where` / `limit`. +- 3644fad: **BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route + `GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key + `reference` alone. The three tolerant fallback arms it used to read after it — the + `referenceTo`, `target` and `options.objectName` spellings — are deleted. + + Effect on the wire: a stored object-metadata row whose lookup field carries one of those + three spellings and no `reference` used to answer `200` with rows from the aliased object; it + now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field + carrying `reference` is unaffected, including a partially-migrated row carrying a legacy + spelling beside it. `publicPicker.object` on the form is still the explicit override and is + still read first. + + No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses + `relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering + with a rename hint naming the canonical key, so no authoring path can produce such a row; a + census across both trees found no producer and no relation field carrying any of them, with + positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to + preserve. The spec spelling is the contract, and a stored row spelling the target the old way + is a producer defect rather than a dialect this route accommodates. + + +- cf6e0a1: fix(rest): a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text (#15071) + + + + **BREAKING** — the answer this published door gives moves for existing inputs. + No export, signature or declared type changes; what changes is the response an + existing call observes, and a client branching on `error.code` for the affected + shape now falls to its 5xx path instead of its refusal path. Shipped as `minor` + under the launch-window convention (`major` is refused while the fixed group + versions in lockstep), so this banner — not the level — is the breaking-ness + signal. + + **What changes for an operator.** A sandboxed hook or action body that declared a + refusal code and then CRASHED — `throw`-ing nothing, but hitting a bug on a later + line — used to answer the single-record `/api/v1/data` routes with the code's own + business status and the QuickJS debug sentence as the client-facing message, for + example `409 DELETE_RESTRICTED · "hook 'guard' threw: TypeError: x is not a + function"`. It now answers `500 UNCLASSIFIED_FAULT` with the sanitised message + and no crash text, which is what the same crash carrying no declared code has + always answered. The full wrapper still reaches the server log through the + existing `[REST] Unhandled error` / withheld-fault path, so nothing an operator + diagnoses with is lost. + + **What does NOT change.** An ordinary declared refusal — a hook that throws a + business error carrying a code and does not crash — is untouched: same status, + same code, same sentence, same structured fields. So is every non-sandbox + producer of those codes, and so is the `developerMessage` channel, which keeps + the rule it already had for a fault. + + **Why.** A declared code is the author's statement about the failure mode they + handled; a crash is not that mode. Answering one with a business status shipped + an internal, stack-shaped sentence to an end user and told the client the wrong + thing about what happened, while the door one branch down already sanitised the + identical crash. Maintainer ruling, 2026-09-04, decision batch #27, on #15071. + + **If you were relying on the old answer,** the affected shape is a hook that + declares one of the classification's ten code-gated refusals and then faults: it + now surfaces as a 5xx to clients and retry policies rather than as a 4xx. That is + the point of the change — the crash was never the refusal the code named. + +### Patch Changes + +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- a900841: fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674) + + The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it. + + Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`: + + - before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted) + - after — both answer `/api/v1/objects` + + The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`. + + **A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn. + + If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly. +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- dfb42c5: fix(rest): `GET /meta/object/:name/state/:field` tells a wired-and-failing engine apart from an absent one (#15405) + + `objectQLProvider` has two consumers in `rest-server.ts`. #13476 repaired one of them — the `computeExecCtx` authorization-input seam — by reaching the provider through `wiredEngineOrLoud`, which keeps "no engine is wired" and "the engine was wired and could not be resolved" as two facts instead of one `undefined`. This route, the slot's second consumer, reached it through `.catch(() => undefined)` and converted every rejection straight back into the `undefined` a never-registered engine produces, three lines before the answer is chosen. So a wired-and-failing engine and a never-registered one both answered `404 NOT_FOUND · "Object not found"` — a diagnostic route lying about the cause during exactly the incident it would be consulted in. + + That line was newly load-bearing rather than long-broken: before #13904 the shipped provider was `try { … } catch { return undefined; }` and could not reject at all, so the `.catch` was dead code. #13904 made the provider re-raise precisely so a consumer could see the outage, and this consumer caught it back. + + **What moves.** On this route only, an engine that is wired and fails to resolve now answers `503 SERVICE_UNAVAILABLE` instead of `404 NOT_FOUND` — the same answer its sibling seam and the package door (#13476) already give for the same fault. No accept set widens and no new wire code is minted: `SERVICE_UNAVAILABLE` is an existing `StandardErrorCode` member, reached through the existing `AuthzStoreUnavailableError`. + + **What does not move.** An engine that was never wired, and a provider that resolves `undefined` (the seam contract declaring absence rather than failing), both keep the `404 NOT_FOUND` they answered before — that is the supported no-data-plane composition. A healthy engine asked about an object that genuinely does not exist still answers `404 NOT_FOUND`; a healthy engine asked about an object that exists is still served. + + **Reachability, stated rather than implied.** Every `/meta` route sits behind the anonymous-deny gate, and that gate resolves the same engine first. Where it takes its provider branch (a single-kernel boot such as `pnpm dev:crm`) a broken engine already raised there, before this route's line ran — so nothing changes for those deployments. The collapse was reachable where a resolvable kernel supplies auth and the separately-wired `objectQLProvider` is broken, which is the multi-kernel wiring, and that is where the new answer lands. + + `POST /email/send` carried the other retired `.catch(() => undefined)` in the same file and moves to `seamOrUndefined`. Its answer is deliberately unchanged at `501 NOT_IMPLEMENTED`; what changes is that a host wiring a **non-`async`** provider — which the seam's declared type cannot prevent — now reaches that same 501 instead of throwing past a `.catch` that did not exist yet and landing in the handler's own `500 EMAIL_SEND_FAILED`. Not reachable from the shipped wiring, where both providers are declared `async`; repaired because it is the same spelling at an embedder-reachable seam. +- 2e8e118: Documentation only: seven in-source prose sites that still stated the superseded readonly-on-INSERT contract as live now state the ruled one. + + The 2026-09-03 maintainer ruling (option C, #14147) put the static `readonly` strip inside `engine.insert` under the same `isSystem` gate as `engine.update`, and deleted the metadata-protocol create-ingress copy. Comments and test headers written before that ruling still said, in the present tense, that a non-system INSERT is exempt from the static strip, or that the strip lives at the DataProtocol create ingress. Each now states the ruled contract, and the superseded sentence is kept only as history, marked as superseded. + + No behaviour changes and no test was deleted, skipped or re-scoped — the diff is comments only. It is a `patch` rather than `skip-changeset` because it was measured to publish: `@objectstack/objectql`'s comment edit moves source line numbers, so `dist/{index,core}.{js,mjs}.map` change, and `@objectstack/rest` inlines that same objectql source into its bundle, so `dist/index.{js,cjs}.map` change with it. Every emitted `.js` / `.mjs` / `.cjs` and every `.d.ts` / `.d.mts` / `.d.cts` is byte-identical before and after, and all six maps ship inside the published tarballs. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-package@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index eacac0afc6..5bb3d9543e 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.4.0", + "version": "17.5.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 8f7444b387..97237dc772 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,340 @@ # @objectstack/runtime +## 17.5.0 + +### Minor Changes + +- 1a25f4a: fix(runtime): `GET /api/v1/packages/:id` honours `?version=` instead of silently ignoring it (#17416) + + The route accepted a `?version=` query parameter and the only surface serving it + never read the parameter. A caller asking for a version that is not installed + was answered `200` with the **installed** row, and nothing in the status, + headers or body distinguished that from a version-scoped read that actually + happened. + + The parameter is not hypothetical traffic: `ScopedEnvironmentClient.packages.get` + (`@objectstack/client`) declares `version?: string` and appends it, so the SDK + has been sending a parameter the runtime dropped. The handler that honoured it + — the REST registrar's twin of this route — was removed with the duplicate + response shape, and the dispatcher's `/packages` domain never had that read to + inherit. + + ``` + FROM GET /api/v1/packages/com.acme.crm?version=99.0.0 (1.0.0 installed) + -> 200 { data: { manifest: { version: "1.0.0" }, … } } + + TO GET /api/v1/packages/com.acme.crm?version=99.0.0 + -> 404 { error: { message: "Package 'com.acme.crm' version '99.0.0' not + found — installed version is '1.0.0'" } } + ``` + + **What does not change.** The unversioned read is untouched, down to the row and + the writability verdict it stamps — pinned as the lit control beside the new + assertions, because a green on only the scoped path would also pass with the + ordinary read broken. `?version=` naming the installed version is served + exactly as the unversioned read is, and so is `?version=latest`: the deleted + handler read `requested.value || 'latest'` and its store resolved `latest` to + the newest row, so "no version" and "`latest`" named one request there and name + one request here. An id the registry does not hold keeps its existing 404 + wording whether or not `?version=` rode along — a package that is not installed + cannot be at the wrong version. + + **This is request-side only.** The response shape is not touched, so the route + still answers with exactly one body shape; comparison is exact string equality + on the version, the same predicate the durable package store uses (`AND version + = ?`), so the two answers to "is this package at version v" cannot drift into + semver-range semantics at one of them. + + A repeated `?version=a&version=b` is no longer resolved by silently choosing + one — it is answered with a refusal naming what was seen. The repo's one rule + for a repeated single-valued parameter answers `400 VALIDATION_ERROR` and is + the right end state for this door too; it is not restated here, because the + helper that owns that rule and its message is not exported from + `@objectstack/rest`. +- 76ddab7: fix(runtime,mcp): `action.ai.requiresConfirmation` is ENFORCED at the AI-facing action door — an unconfirmed call is refused, and `run_action` grows the `confirm` member that satisfies it (#15942) + + **Behaviour change — read this if any of your actions declare `ai.requiresConfirmation: true`.** An AI-facing invocation of such an action (`invokeBusinessAction`, reached from the MCP `run_action` tool) is now REFUSED unless the request carries the confirmation member. A call that succeeded before starts answering `428 ACTION_CONFIRMATION_REQUIRED`, and nothing dispatches: the action body does not run, and the subject record is not even read. + + FROM → TO, for a caller of a gated action: + + ``` + run_action({ actionName: 'archive_lead', recordId: 'lead_1' }) // was: ran + run_action({ actionName: 'archive_lead', recordId: 'lead_1', confirm: true }) // now: required + ``` + + The refusal is machine-readable so the retry is mechanical rather than guessed — `error.details` carries `{ actionName, objectName?, confirmationMember }`, and `confirmationMember` echoes the member's exact spelling (`AI_ACTION_CONFIRMATION_MEMBER`, `@objectstack/spec/contracts`). The `run_action` tool schema advertises `confirm` as an optional boolean, so an agent discovers the retry from the tool definition rather than from prose. + + **What is NOT gated**, because this narrows a published accept set and the narrowing is deliberately as small as the author's own declaration: + + - Only the DECLARED flag gates. `ai.requiresConfirmation: true`, set by the action's author, and nothing else. The wider `list_actions` heuristic — `mode: 'delete'` / `variant: 'danger'` on an action whose author declared nothing — still reports `requiresConfirmation: true` to advise a client, and still does NOT refuse. An explicit `ai.requiresConfirmation: false` never refuses. + - Only the boolean `true` confirms. `'true'`, `1` and `false` are not attestations. + - Only the AI-facing doors. The enforced set is the doors that enforce `ai.exposed` — today `invokeBusinessAction` via MCP `run_action`. REST `/actions` is not `ai.exposed`-gated and sits outside this gate. + - `list_actions` is unchanged. + + **A gate, not a queue.** Nothing is parked, nothing is held for an operator, and there is no resume path: a refused call simply did not run, and the caller confirms with its human and retries. And `confirm: true` is an unverifiable caller claim — an agent that always sends it bypasses the gate. The gate makes FORGETTING loud; it does not prove a human. + + Why it is worth the break: the flag was read once and consumed once, to fill a field of the `list_actions` summary. It stopped nothing. That is the failure ADR-0049 retired `tool.requiresConfirmation` for — "a SAFETY flag that is merely accepted is false compliance" — reappearing on the very key the retirement's own ledger entry told authors to move to. The contract this implements landed in `@objectstack/spec` first (#16293). +- ea4d164: Bind an environment artifact's install-time GRANTED permission set to the packages that artifact materializes. + + `EnvironmentArtifactSchema.grantedPermissions` — the consented `{ services, hooks, network, fs }` set the control plane compiles onto the artifact at install-consent time (ADR-0025 §3.5 step 2 / F4) — now reaches `PluginPermissionEnforcer.registerGrantedPermissions` at materialize time, one call per consent record, keyed by the plugin manifest `id`. `AppPlugin.init()` performs the binding, so it happens on every path that turns an artifact into a kernel plugin without either caller changing a line, and the enforcer holding the result is readable as `AppPlugin.permissionEnforcer` (with `AppPlugin.grantBinding` recording what bound). + + Absent, `{}` and a consented entry stay three distinct states. An artifact carrying no `grantedPermissions` key allocates no enforcer and registers nothing, so a package with no consent record loads exactly as it did; a per-plugin `{}` is a consent record that consented to nothing and registers a bag that denies every service, hook, host and path. A consent record naming a package the artifact does not carry is reported at `warn` rather than passing in silence. + + Fixed alongside, because without it the binding was unreachable: the `{ schemaVersion, metadata }` envelope unwrap in `loadArtifactBundle` handed the kernel `metadata` alone and dropped every key standing beside it, so an envelope artifact reached the kernel with `grantedPermissions` stripped. The loss was silent and indistinguishable from the legitimate absent reading. The unwrap now carries the key across when the envelope declares it, `{}` included, and never invents one. + + New exports from `@objectstack/runtime`: `registerArtifactGrantedPermissions`, `resolveArtifactGrantBinding`, `carriedPackageIds`, `ArtifactGrantBinding`. + + This is the registration half. Access-time enforcement runs through `SecurePluginContext`, which no production path constructs; that seam is ADR-0025 install-flow work and is unchanged here. +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 4280055: fix(runtime): mount the scoped `/api/v1/environments/:id/packages*` door, and reconcile the package read/delete responses to their declared schemas (#16781) + + **The door.** `mountPackagesRoute` mounted `/packages*` at the unscoped prefix only, while automation / actions / ai each registered a scoped variant twenty lines away. On a host composed as `@objectstack/plugin-hono-server` + this plugin with `enableProjectScoping: true` and **without** `@objectstack/hono`'s `createHonoApp`, that left `GET /api/v1/environments/:id/packages`, `GET …/packages/:id` and `DELETE …/packages/:id` answered by the transport's own `notFound` — a bare 404 on routes `content/docs/api/environment-routing.mdx` documents. The domain has resolved scoped package paths since #15859; nothing mounted one. + + `mountPackagesRoute` is now wrapped in a `base`-taking `registerPackageRoutes(base)`, exactly like its three siblings, and called a second time with the scoped base. **The same handler, no second implementation.** The unscoped mounts keep their registration position and their unconditional mounting, so the change is purely additive: no route that answered before stops answering. + + **The wire.** Two responses gained the key their own declared schema requires (contract review of #16628, finding F2). Both additions are **additive** — no key left either payload: + + - `GET /packages` now sends **`hasMore`** (`ListInstalledPackagesResponseSchema`). It is `false`: this door applies its `status` / `type` filters and returns every remaining row, reading no `limit` and no `cursor`, so there is no next page to announce. + - `DELETE /packages/:id` now sends **`packageId`** (`UninstallPackageApiResponseSchema`). `registryRemoved` and `persisted` stay on the wire unchanged. + + A client that reads only the keys it read before is unaffected; a client parsing either payload against the published schema stops being refused. + + The `DELETE /packages/:id` route-ledger row now carries `responseSchema: 'UninstallPackageApiResponseSchema'`, backed by new conformance coverage that drives the real handler. `GET /packages` is deliberately left blank: its rows are the ASSEMBLED package body, while `InstalledPackageSchema` wraps the AUTHORING-stage `ManifestSchema` — the #14242 stage mismatch, which no `@objectstack/spec/api` export declares yet. Both directions of that boundary are pinned, so the row becomes fillable against a red test rather than a guess. +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. + +### Patch Changes + +- cea85fd: A sandboxed hook's business refusal reached through a script action answers 4xx, not `500 INTERNAL_ERROR` + + `POST /api/v1/actions/:object/:action` answered **`500 INTERNAL_ERROR`** when a + `beforeUpdate` hook refused a state transition for a business reason and the + refusal travelled out through the action body's `ctx.api` write. The same refusal + has answered **`400`**, with the hook's sentence verbatim, on `/data` since + objectstack#11588. A 500 tells every client "the platform broke", so a + well-behaved one retries, alerts or pages for a guard that will never say yes. + + **Where the producer was.** Not in the action route's classifier — that read the + shape it was handed correctly, and both sides of the line it pins (`a deliberate + REJECTION is a 400` / `an unexpected FAULT is a 500`) are unchanged. The refusal + arrived already stripped of every mark that says "a body reported this on + purpose", one VM hop earlier: `hostErrorToVm` marked **every** `SandboxError` + crossing into the action body's VM as the sandbox's OWN fault (objectstack#4431) + on an `instanceof` test — and a nested sandboxed hook's refusal *is* a + `SandboxError`, wrapped by the same runner one level down. The pump branch that + reads that marker then discarded `innerMessage`, `code`, `status` and `fields`, + and the classifier read the missing business message as a crash. + + **What changed.** The marker now asks the question the `/data` door asks — + `sandboxBusinessMessage`, objectstack#11588 — instead of testing the error's + class. Both of that predicate's conditions travel, because both are load-bearing: + a capability denial carries no business message and stays a fault, and a nested + body that **crashed** carries `TypeError: …` and stays a fault too. + + **No status was picked for this route.** It matches what `/data` already answers + for the same producer: the status the body declared, or `400` when it declared + none. A refusal that declares `{ status: 409, code: 'RECORD_LOCKED' }` now + reaches the caller as `409 RECORD_LOCKED` instead of losing both. + + **The sentence a caller receives is byte-identical to what the 500 carried** — + this moves the status, not the prose. The flattened `SandboxError: ` name prefix + is stripped on the rejection path by the same helper the fault path already used. + + No authorable key, accept set or export surface moves; no consumer needs a + change. Clients branching on 5xx to decide whether to retry will stop retrying + these refusals. +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [04333d0] +- Updated dependencies [07f93e0] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [b110578] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [29d00cc] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [4062aef] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [5505646] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/metadata-protocol@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/metadata@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/formula@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/observability@17.5.0 + - @objectstack/service-cluster@17.5.0 + - @objectstack/service-i18n@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 806bd2c1de..574be8b7a5 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.4.0", + "version": "17.5.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 8b049eb7eb..4438b74876 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,19 @@ # @objectstack/sdui-parser +## 17.5.0 + +### Patch Changes + +- f55922f: `dashboard-widget-options.ts` header: `stageOrder` is a `funnel`-only key, not `funnel` / `pyramid` + + The accepted-set census comment at the top of the module (carried into the + published `index.d.ts`) described `stageOrder` as "funnel/pyramid stage order". + There is no `pyramid` widget type: `ChartTypeSchema` refuses it, so an author + who copied the pair got a parse refusal. The line now says what the schema's + own `.describe()` says: `funnel` is the only widget type that reads the key. + Comment-only — the accepted set, the diagnostic code and the emitted JS are + unchanged. + ## 17.4.0 ## 17.3.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index ec3eb36b9c..4314bdc9ef 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.4.0", + "version": "17.5.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 c49f233bab..2684f5c646 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,859 @@ # Changelog — @objectstack/service-analytics +## 17.5.0 + +### Minor Changes + +- e526556: fix(service-analytics): a `min`/`max` over a `formula` field is typed from the formula's declared `returnType`, not described as `number` (#16236) + + **Behaviour change — read this if any dataset measure aggregates a `formula` + field.** `AnalyticsResult.fields[].type` for such a measure column was always + `number`, whatever the formula computes. It is now translated from the field's + declared `FieldSchema.returnType`: + + ``` + FROM {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"number"}, + {"name":"latest_due","type":"number"}]} + + TO {"rows":[{"first_label":"alpha","latest_due":"2026-06-01"}], + "fields":[{"name":"first_label","type":"string"}, + {"name":"latest_due","type":"time"}]} + ``` + + Both values were strings; both descriptors said `number`, so a renderer that + branches on the declared type never reached its textual or temporal branch. + + **The mapping is a TRANSLATION, not a pass-through.** `returnType` speaks the + authoring vocabulary (`number` / `text` / `boolean` / `date`); + `fields[].type` speaks `DimensionType` (`string` / `number` / `boolean` / + `time` / `geo`). Two of the four words do not exist on the wire at all: + + | declared `returnType` | `fields[].type` | + |:---|:---| + | `text` | `string` | + | `date` | `time` | + | `number` | unchanged — the producer's `number` is already correct | + | `boolean` | unchanged — three readings disagree on what `min`/`max` over a boolean returns | + + **A formula with no `returnType` is unchanged.** The key is optional — "absent + when the type can't be proven (an ambiguous/`dyn` expression)" — and an + unproven formula's measure column keeps the `number` it had. The absence is not + read as an answer. That tier is written down as a row in `measureResultType`'s + own table rather than left as an implied code path, and so is the treatment of + a word outside the declared four: left alone, never guessed at. + + **For hosts wiring `AnalyticsService` directly.** `AnalyticsServiceConfig`'s + `sourceFieldMeta` hook gains an optional fourth member on its return — + `returnType?: string` beside `type` / `defaultCurrency` / `max`. Additive: a + host that returns the three-member shape still satisfies the contract and gets + exactly today's behaviour for every column. `AnalyticsServicePlugin` relays the + key automatically, so a host on the plugin needs no change at all. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 5d12b16: fix(service-analytics): the ROW-SCOPE bridge to the `security` service tells the same three resolutions apart as the object-level one — a broken security service refuses the query instead of running it with no row policy (#16918) + + `AnalyticsServicePlugin` bridges to the `security` service twice: once for the OBJECT-level read grant (`admitObjectRead` → `canReadObject`, #16645) and once for the ROW-level read scope (`getReadScope` → `getReadFilter`, ADR-0021 D-C). The object-level bridge tells three resolutions apart — ABSENT admits, THROWING and METHOD-LESS deny at `error`. The row-scope bridge collapsed all three into one: + + ```ts + const trySecurity = () => { + try { + const svc = ctx.getService('security'); + return svc && typeof svc.getReadFilter === 'function' ? svc : undefined; + } catch { return undefined; } + }; + getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context); + ``` + + A throwing resolver and a registered service without `getReadFilter` both produced `undefined` — the same value an absent security service produces, and the value `ISecurityService.getReadFilter` reserves for one meaning only: *"this caller has no row restriction on this object"*. So on a deployment whose security service was wired but broken (a boot-order fault, a mis-registered plugin, a failing dependency, a provider that is not the contract it claims to be) analytics queries ran with **no row-level policy at all**, and nothing said so. One door of the file failed closed on a throwing resolver and its neighbour failed open — and the neighbour is the one carrying row-level policy. + + **What changes.** The bridge now resolves the same explicit three-way, at the same reporting level: + + - **ABSENT** — no `security` service resolved: **unchanged**. No row-scope provider on this deployment, which is a legitimate configuration (a single-tenant kernel that ships no `plugin-security`, where `/data` carries no row-level policy either) and is already reported loudly at init. ⛔ Deliberately not tightened: refusing here would break every such deployment. + - **THROWING** resolver, or a registered service with **no `getReadFilter`** — the query is **REFUSED**, and the reason is reported at `error` naming the object and which of the two states it was. The refusal is a throw, which `AnalyticsService.resolveReadScopes` — fail-closed since ADR-0021 D-C — already turns into "deny the whole query rather than emit SQL with that object unscoped". A log over an `undefined` would not have been a refusal. + + **This change only NARROWS what analytics serves, and only in a state where the security service is broken.** No deployment with a working `security` service, and no deployment with none, changes behaviour by so much as a byte. Nothing that was refused becomes admitted. + + **No published-surface delta.** No new error code (the refusal rides the seam's existing fail-closed error), no exported symbol, no key on `AnalyticsServicePluginOptions` or any payload, and no documented envelope changes shape. Graded `minor` rather than `patch` because it is a behaviour narrowing on a published package's read path, matching how its object-level sibling was graded in the same lockstep window. + + ⚠️ Deliberately **not** answered here: which tenant wall the platform's is (plugin-security's posture-gated Layer 0, or driver-sql's posture-independent auto-scope) — the escalated maintainer decision of triage condition 5. Refusing to serve is neutral between them: it answers *"should we serve at all"*, never *"what shape is the wall"*. +- 634f23d: fix(analytics)!: `AnalyticsServiceConfig.sqlDialect` declares its three-name accept set, and a host that answers outside it is told once (#16206) + + + + **BREAKING** for a TypeScript host that declares its `sqlDialect` hook as returning + `string`: the hook's declared return is now the three canonical dialect names or + `undefined`, so such a composition stops compiling until the host's own annotation + says which names it can answer. Shipped as `minor` under the repo's launch-window + convention, in which breaking-ness is carried by this banner and the disposition + above rather than by the bump level. Runtime behaviour for every host is unchanged: + the same three names were the only ones that ever did anything. + + ## What was wrong + + `AnalyticsServiceConfig.sqlDialect` — the hook a host answers to say which SQL + dialect backs an object — was typed as free `string`, while `normalizeSqlDialect` + has only ever recognised `sqlite`, `postgres` and `mysql`. Nothing said so, and + nothing told a host that answered otherwise. + + So a host that owns a SQLite datasource and answers the spelling its own stack uses + — knex's canonical `sqlite3`, or `better-sqlite3`, both of which `driver-sql` itself + lists in `SQLITE_EMIT_CLIENTS` — was read as `unknown`. And because `sqlDialectFor` + is tiered "cannot answer, do not block", **a wrong answer and no answer were the + same answer**: the host that tried hardest to help got the residue arm, silently. + + ## What it does now + + - **The vocabulary is declared**, on the type and in the docblock, as + `AcceptedSqlDialect` — `sqlite` | `postgres` | `mysql` — so a host reading the + config learns the accept set without running anything. The type and the runtime + membership set are generated from one `const` tuple, so a future widening cannot + land in one and miss the other. + - **A non-empty answer outside the set is diagnosed**: one `warn` naming the object, + the answer and the accepted set. It is emitted **once per distinct unrecognised + spelling** — the failure's identity — so the line count is bounded by the host's + own hook and never grows with query volume. + - **`undefined` stays silent and legal.** The hook is optional and "cannot answer, + do not block" is a supported composition, not a misconfiguration. A pin holds both + halves, because a diagnostic that also shouted at hosts who wired nothing would be + a worse defect than the one being fixed. + - **The accept set is NOT widened.** Teaching this package `driver-sql`'s knex + aliases would be a second copy of that driver's table, and an unrecognised + spelling is sometimes deliberate (`mariadb`, #11756). The answer is still read as + `unknown`; only the silence changed. + - **The plugin bridge translates the driver's own residue.** `SqlDriver.dialectName` + carries a fourth name, `unknown`, meaning "I cannot say"; handed on verbatim it + would have presented a correctly-behaving driver as a host answering out of + contract. It now arrives as `undefined`, this hook's own spelling for the same + thing. The dialect the compilers end up with is unchanged either way. + + ## Measured, and worth reading before relying on the residue arm + + Driven on sql.js through a host answering `sqlite3`, against the shared + `FILTER_TEXT_CASES` fixture, with a host answering `sqlite` as the control: **five of + the six case-EXACT cases come back with the wrong rows** — every case that + discriminates on ASCII case. `{ name: { $contains: 'acme' } }` answers `['1','2']` + where the table says `['2']`, and the negated form DROPS a row that belongs in the + result. That is #15684's fold, live on the arm this population lands on, and it is + reported rather than fixed here: closing it is that card's business, not this one's. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. They are under #16785, **ruled + C**: the table itself is to be amended to accept them, because + `measureResultType` (#15768) already types those results as `'string'` and + pins them end to end. Enforcing them from this card would pre-empt that + ruling. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. + + ⇒ The only pairs whose behaviour changes in this release are `avg` / `sum` + over a `date` / `datetime` / `time` field. The full-table leg remains #16099's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- 3c557e2: **The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains + one new optional key, `translateSelectOptions`** — the surface the level is graded against, + per the same "a new key on a published exported type is the mechanical floor for clause ②" + rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, + no wire-shape change), so `minor` rather than `major`. + + A dataset's `select`-field dimension now renders its option label in the request's locale on + a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list + grid) already renders for the identical field. + + `dimension-labels.ts` resolved a select dimension's category label straight out of field + metadata's authored `options[].label` — always the author's own-language text, since + `SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted + cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field + name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` + skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on + that path, and a regression test now pins that it is never even consulted. + + `DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the + plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) + — the SAME translator the object-metadata REST endpoint already uses — against the + deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec + key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, + and a kernel with no i18n service configured (or nothing for the requested locale) falls back + to exactly today's authored-label text. + + A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / + master_detail labels resolve through the separate `fetchRecordLabels` capability (a related + RECORD's display name, not a field's authored `options[]`), which this change does not touch. + It does lower the cost of adding translated lookup-record labels later, though — the i18n + service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already + wired into this package and is a `ctx.getService('i18n')` away from reuse. +- e66da5c: feat(service-analytics)!: a dataset measure applying `sum` or `avg` to a field whose declared type cannot carry it is refused at compile time, for every field type and not only the temporal class (#16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, continuing the + one #16778 began. A dataset measure pairing `aggregate: 'sum'` with a `text` field (or + `avg` with a `select`, `json`, `lookup`, `formula`, … field) used to compile and reach + the backend; it is now refused by `compileDataset` with `DATASET_INVALID` / **400** + before any query is built. Shipped as `minor` under the repo's launch-window convention + for accept-set narrowings. + + ⛔ This changeset adds no rows to any table and restates none. The verdict is + `AGGREGATE_FIELD_TYPE_COMPATIBILITY`'s — the one table `@objectstack/spec` declared in + #16353 under the director ruling of decision batch #59 ("both legs, table in spec") — + read through `isAggregateCompatibleWithFieldType`. + + ## What was wrong + + #16778 landed the compile leg SCOPED to temporal source fields, leaving "every other + non-temporal pair the table refuses" as a stated residual that had never been driven. + Driven on this card, through the real service door: + + ``` + sum × text the table refuses the pair the compile leg does NOT throw SQL IS emitted + sweep 6 aggregates × 49 field types = 294 pairs; 155 refused by the table; + minus 6 temporal (#16778's) minus 42 `min`/`max` × the string classes; + residual 107 — and 107 of 107 were ACCEPTED by the compile leg + control avg × datetime / date / time → DATASET_INVALID / 400, no SQL emitted + ``` + + The control is what makes that a reading of the tree rather than of a blind harness: the + same service, door and `sourceFieldMeta` hook sees the pairs #16778 enforces refused. + + So `sum` over a `text` column reached whichever backend the object is bound to, and the + answer was a property of the dialect rather than of the data — the shape Prime Directive + #12 exists to remove, and the same shape #16778 closed for one field class. + + ## What it does now + + - `compileDataset` judges a measure whose aggregate DERIVES a number (`sum` / `avg`) + against the table for **every** declared field type, and refuses an unaccepted pair + with `DATASET_INVALID` / **400** — naming the measure, the field, its declared type + and the accepted set read off the table. Nothing reaches the driver. + - `sum` × `percent` is refused at last: the row `analytics-service.ts` has called + "incoherent" in a comment since before the table existed. `avg` × `percent` is still + ACCEPTED by the same table, which is what makes it a row and not a class. + - The refusal's closing prescription is now chosen by the source field's class: the + temporal sentence #16778 measured is kept verbatim for temporal fields, and a + non-numeric field is pointed at `count` / `count_distinct`, which accept every type + because they read no arithmetic off a value. + - Unchanged: `derived` is covered by construction (a dataset carrying a refused base + measure never finishes compiling), and the three "cannot answer, do not block" tiers — + no `sourceFieldMeta`, an unresolvable field, a `relationship.field` path. + + ## ⚠️ Scope: the DERIVING aggregates. `min` / `max` are still not judged here + + `min` / `max` SELECT one of the stored values; `sum` / `avg` DERIVE a number. This is the + line this package already draws — `measureResultType` branches on exactly that pair of + aggregates — and the defect is about a derived number, so the deriving aggregates are its + population. + + The `min` / `max` rows stay with **#17513**, and that is measured rather than assumed. + Enforcing the residual whole was tried on this card: with `min` / `max` × the string + classes subtracted, **15** cases in `measure-result-type.test.ts` still went red, every + one of them on `min` × `json` — a pair the table refuses, in no ruling's scope, driven + end to end by the same shared fixture as the string rows. One dataset compiles every + measure in that fixture, so one refused pair reds the whole section. ⇒ `min` / `max` is + one question, and it is the table-amendment card's. + + ## Upgrading — FROM → TO + + Nothing an author writes is removed or renamed: `DatasetMeasure.aggregate` and + `DatasetMeasure.field` keep their spellings and their types. What narrows is which PAIRS of + values are accepted. The one-line fix, per shape: + + | FROM (compiled before, refused now) | TO | + |---|---| + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'count_distinct', field: }` — counting reads no arithmetic off the value | + | `{ aggregate: 'sum' | 'avg', field: }` | store the quantity you meant as its own numeric field and aggregate that | + | `{ aggregate: 'sum', field: }` | aggregate the formula's numeric INPUT column; a `formula` is virtual in SQL storage, so no arithmetic aggregate can be lowered to it | + | `{ aggregate: 'sum', field: }` | `{ aggregate: 'avg', field: }` — a rate averages, it does not add | + | `{ aggregate: 'sum' | 'avg', field: }` | unchanged from #16778: use `min` / `max` for a real instant, or store a duration as a number and aggregate that | + + `min` / `max` are **not** affected by this change at all, over any field type. + + No shipped dataset in this repository declares a newly-refused pair — every one of the + eleven shipped dataset measures resolves to `number`, `currency`, `summary` or `progress`. + The refusal names the accepted set for the aggregate, read off the table. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- 86c5052: fix(analytics): a `dateRange` array that is not a two-bound window is refused, once, instead of meaning three different things (#17124) + + `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no + length constraint, so `dateRange: ['2026-01-01']` is schema-valid and reaches the + analytics faces through `POST /analytics/dataset/query`, which types its selection + from `AnalyticsQuery` and never Zod-parses it. The four faces in this package that + read the arm answered it three different ways — measured over one authored + document and four rows: + + | face | `['2026-01-01']` meant | + |---|---| + | `ObjectQLStrategy.dateRangeBounds` | the point window `created_at >= '2026-01-01' AND <= '2026-01-01'` | + | `NativeSQLStrategy` | no time clause at all — the whole dataset | + | the draft-preview evaluator | an upper bound of the string `"undefined"`, which every ISO date sorts below — everything from that day onward | + | `DatasetExecutor`'s `compareTo` pass | the point window, shifted — compared against a primary pass that may have read all of history | + + For a dashboard that is one day's number, the whole dataset's, and everything + from that day onward, from the same document, decided by which backend answered. + `[]` and `[a, b, c]` split the same three ways, and `[null, null]` reached + `parseUTC(null)` as a bare `TypeError` — a 500 for a malformed request. + + One rule is now the single reading of the arm and all four faces call it; the + three divergent fallbacks are deleted. An array that is not exactly two string + bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 + envelope — the answer the contract already gives for a `dateRange` that does not + denote a window. A two-element window is untouched on every face, bound for + bound, including the inclusive upper reading a caller's bounds keep (#16179) and + the half-open bare-day widening on the SQL side (#3777). + + ### Write both bounds + + | wrote | write instead | + |---|---| + | `dateRange: ['2026-01-01']` | `dateRange: ['2026-01-01', '2026-01-01']` | + + That spelling already selects exactly that one day on every face, and it is the + same instruction #16322 shipped for the single-day string dialect. + + ⭐ Shipped as `patch`, not as a breaking narrowing, because nothing DECLARED + moves. The spec's own refusal wording already states that *"an explicit window is + the two-element array [start, end] of ISO dates or {date-macro} tokens"*, and + #16322's shipped migration table already told authors to write a single day as + `['2026-01-20', '2026-01-20']`. A one-element array was therefore never a valid + document; it was an invalid one that four faces answered arbitrarily, and a + behaviour that was never one behaviour is not a behaviour this removes. The Zod + type admitting the shape is weaker than the contract the same file states — + tightening it is a separate, spec-owned question. +- 40098a4: fix(service-analytics): an unrecognised `compareTo.kind` is refused, not answered with a previous-period window under a 200 (#17550) + + `shiftRange` had one branch and a fall-through — `previousYear` was named, and + **everything else** landed in the `previousPeriod` arm. No `default`, no + exhaustiveness check. So `compareTo: { kind: 'previousQuarter' }` came back as a + previous-period comparison under an ordinary **200**, and the caller was told + nothing. The wrong answer is a comparison **window**: a number a dashboard + renders and a person reads as fact, with no status, header or field in the + response to distinguish it from a real answer. + + `DatasetCompareTo.kind` has only ever declared two values + (`'previousPeriod' | 'previousYear'`), but `DatasetSelection` is a TypeScript + interface with no Zod schema anywhere, and `/analytics/dataset/query`'s door + parses only the seven members the selection shares with `AnalyticsQuery` — + `compareTo` is one of the four it projects away before its parse, and the route + forwards the caller's selection to the service untouched. So `kind` was checked + by `tsc` inside this repo and by nothing at all on the wire. + + ## FROM → TO + + | Input | Was | Now | + |:--|:--|:--| + | `compareTo: { kind: 'previousPeriod' }` | the equal-length window before | **unchanged** | + | `compareTo: { kind: 'previousYear' }` | the same window one year back | **unchanged** | + | `compareTo: { kind: }` | a previous-period window, **200** | `DATASET_INVALID` / **400**, naming the value received and both legal ones | + + The fix is to name one of the two declared windows, or drop `compareTo` — which + is what the refusal says. No accept set widens, no new error code is minted: the + refusal is the fourth member of the `datasetInvalidError` family + `resolveCompareDimension` already raises three times for the same document, so it + arrives at the route through the envelope that route already classifies on. + + ## Why this is a `patch` + + It pulls behaviour back onto the contract the type has always declared, rather + than narrowing past it: every input `DatasetCompareTo` permits returns + byte-identical windows, pinned by a control in the same change. What flips from + 200 to 400 is input the declared contract never permitted. The reachable-today + population for that input was measured on the tree — the dashboard authoring path + is already doored (`DashboardWidgetSchema` parses the widget's `kind` as a + `z.enum`, so a third kind cannot arrive through a parsed widget), and no producer + in this repository sends a third value. What is not enumerable from here is a + consumer outside it calling the published `shiftRange` export, or posting a + hand-rolled body to the dataset route; for those, the refusal replaces a wrong + answer with a located one. + + `alignedCompareBucketKey` reads the same two-valued `kind` and deliberately gains + no refusal of its own: it is not on the package's public surface, and its only + caller runs `shiftRange` first — both pinned, so exporting it turns the pin red + rather than silently reopening this defect. +- 113050e: A dataset dimension over a `user` or `tree` field renders the referenced record's display name, the same way a `lookup` dimension already did. A "by person" chart's axis is people's names, not a column of user ids. + + `packages/spec` declares one reference class — `REFERENCE_VALUE_TYPES` = `lookup`, `master_detail`, `user`, `tree`, "value points at another record … a record-id string in stored form" — and this service already treated it as one class where it annotates measure result types (`measure-result-type.ts` imports that very set). The label resolver, one file away, hand-wrote a two-member subset of it (`lookup`, `master_detail`), so within a single dataset query one axis came back as a name and the other as a raw id, for two fields that differ in one word: + + ``` + Field.user({ label: 'Person' }) -> { type: 'user', reference: 'sys_user' } + Field.lookup('sys_business_unit', { … }) -> { type: 'lookup', reference: 'sys_business_unit' } + ``` + + - **The subset is gone, not extended.** The resolver now asks `referenceTargetOf` (`@objectstack/spec/data`) — the declared single arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass, the `#3680` sort-key hook's `isLabelBearing`, and its `resolveLabels`. Adding two literals to a private `Set` would have left the next member of the class to be re-reported by the next user. + - **A `user` field authored without `reference` resolves too.** `sys_user` is a constant of the type, which `referenceTargetOf` materializes; requiring an author to restate it is exactly the disagreement between two readers of one field that arbiter exists to end. + - **The label read stays scoped (`#3602`).** Turning a user id into a name is a read of `sys_user`, and it travels the same `LabelScopeResolver` path every other member of the class travels — the referenced object's own RLS is resolved and ANDed into the lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This is the half of the change that had to land with it, not after it. + - **Nothing degrades into an error or a blank.** An orphaned or RLS-hidden user id, a `sys_user` with no display field, and a user object unknown to the engine all leave the raw id in place and answer the query, which is the pre-existing contract for an unresolved lookup id. + + No new authorable key and no new export: `DatasetDimensionSchema` is untouched, and a dimension's own declared `type` still does not decide this — the resolver reads the object field's type, as it always has. +- 54b3d1d: fix(service-analytics): a fail-closed row-scope refusal can no longer be served as an empty chart (#17130) + + `queryDataset` degrades to `{rows: [], fields: [], totals: []}` when a BARE error looks like a driver reporting an absent table — a deliberate leniency (#5033) so a dashboard widget over an unmounted object renders "no data" instead of failing. The test is a substring match over the message, and three of its six limbs — `not registered`, `unknown object`, `is not a registered object` — are exactly the phrasings a registry or security refusal reaches for. + + Both sites of the row-scope RESOLUTION stage refused with a bare `throw new Error(…)`: the `security` bridge in `AnalyticsServicePlugin`, and `AnalyticsService.resolveReadScopes`. They propagated only because their wording happened to miss all six — so any reword, or any refusal added to that stage later, could silently turn a fail-closed gate into a `200` with no rows. + + Both now declare `READ_SCOPE_COMPILE_FAILED` / `500` — the code the sibling read-scope LOWERING stage has answered with since #5367, so the registered wire vocabulary is unchanged. Two visible consequences for a deployment whose wired `security` service cannot answer a row-level read scope: + + - the refusal reaches the caller as a declared `500` instead of relying on its phrasing to escape the degradation path; + - its message is withheld from the response body by declaration (the operator still gets the full text, at `error`, from the producing site) rather than echoed. + + Every refusal message is byte-unchanged, and #5033's leniency is untouched: a genuine absent source table still degrades to the empty result with its `warn`, and a deployment with NO security service still runs unscoped exactly as before. A guard derived from the source (`refusal-wording-collision.test.ts`) now walks every `throw` in the package and fails if an un-enveloped refusal can be read as a missing source table. +- f3b28eb: Draft-preview analytics: `avg` answers the mean of the NON-NULL operands, and `null` when there are none — matching every live face + + A dataset measure `{ aggregate: 'avg', field: 'amount' }` compiles to the cube + metric `{ type: 'avg', sql: 'amount' }`, and the draft-preview evaluator built + its operand list with `rows.map((r) => Number(r[field]))`. `Number(null)` is `0` + and `Number.isFinite` accepts it, so every NULL entered the average as a zero + OPERAND and was counted in the divisor. `AVG(col)` is defined over non-null + values in every SQL dialect, so a drafted chart showed a different number than + the published one, silently — and where a group's column was NULL in every row + the number it showed was `0`: a plausible-looking average that a reader cannot + tell from one somebody measured. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite). Rows `{meals, null}` and `{meals, null}` answered + `avg_amount` null live and `0` on preview; rows `{travel, 10}`, `{travel, 20}`, + `{travel, null}` answered 15 live and 10 on preview. Both cells now answer the + live number. + + The empty answer is READ from the platform's own ruling rather than restated + here: `emptyGroupValueFor` (`@objectstack/spec/data`) returns the identity `0` + where counting or summing nothing is a measured fact and `undefined` — spelled + `null` on this wire — where there is nothing to answer. It is the same function + `fillEmptyGroups`, `sql-driver` and `driver-turso` read, and the one #16203 cited + when it moved `min`/`max` off the same idiom in this function. + + Unchanged, and pinned by the same differential: `sum` over a group with no values + still answers the ruled identity `0`, `count` over one still answers `0` + (#16218), `min`/`max` still answer `null` (#16203), and `avg` over a group that + has values still answers its mean. `sum` and the numeric `default` arm keep their + existing operand list — `0` is the additive identity, so the coercion never moved + `sum`'s answer, and the `default` arm serves the custom-SQL metric types, which + have no live standard to be moved towards. + + The `null` fires on an EMPTY group and never on an incoherent one. "No numeric + operand" is two different situations: no row carried a value at all — the empty + group the policy rules on — or rows carried values that do not read as numbers, + such as a `date` column under `avg`. The second is an incoherent + aggregate/field-type pair that #16099 owns and no layer refuses yet; it keeps the + numeric identity it has always had, since the live face answers a different + number again (SQLite's numeric affinity over a TEXT column) and a `null` there + would invent a third answer. That boundary is pinned from both sides — by + `preview-aggregate-operand-type.test.ts` (#16203) and by a control in the new + differential. + + The live path is unchanged. + + Bumped `patch` rather than `minor`, on the same reasoning the sibling #16218 + shipped under: the package's published surface is byte-unchanged — `src/index.ts` + is not in this diff and does not re-export `preview-evaluator.ts` at all, and + `aggregate()` is module-private — and the only user-visible effect is a drafted + chart's number moving to the number the published chart already showed. A value + correcting toward the live standard is a fix, not the backwards-compatible + feature addition `minor` denotes. It is a real value change for a consumer + reading the preview response (`0` becomes blank), which is why the card was filed + separately rather than ridden along with #16203 — but the `0` it replaces was + never a number the platform promised. +- fd5cff2: Draft-preview analytics: `count` over a declared field counts its non-null values, matching every live face + + A dataset measure `{ aggregate: 'count', field: 'payer' }` compiles to the cube + metric `{ type: 'count', sql: 'payer' }`, and the draft-preview evaluator carried + that field in and never read it — it answered the ROW count, nulls included, + while every SQL face lowers the same measure to `COUNT("payer")`, defined over + non-null values. A drafted chart therefore showed a different number than the + published one, silently, and the number it showed was the one `count(*)` gives: + the author's choice to count a specific column had no effect on the preview path. + + Measured on one dataset, one row set, two `AnalyticsService` instances differing + only in `draftRowsResolver` (the live half being `NativeSQLStrategy`'s generated + SQL on a real SQLite): rows `{meals, 'bob'}` and `{meals, null}` answered + `payer_count` 1 live and 2 on preview. Both now answer 1. + + Unchanged, and pinned by the same differential: `count` with no field and `count` + with `field: '*'` still answer the row count (the compiler writes + `sql: m.field ?? '*'`, so the star is the "no field declared" spelling), and + `count_distinct` still answers a cardinality. A group in which no row carries a + value counts `0`, never null — `emptyGroupValueFor` rules counting nothing the + identity `0`. + + The live path is unchanged. + + Bumped `patch` rather than `minor`: the package's published surface is + byte-unchanged — `src/index.ts` is not in this diff, `aggregate()` is + module-private and `evaluateAnalyticsQueryOverRows` is not on the barrel — and + the only user-visible effect is a drafted chart's number moving to the number + the published chart already showed, which is a correction toward the live + standard rather than the backwards-compatible feature addition `minor` denotes. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 376df4e580..877a81b434 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.4.0", + "version": "17.5.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 7bfd87c6e3..993a38eed4 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,481 @@ # @objectstack/service-automation +## 17.5.0 + +### Minor Changes + +- 92865f6: fix(service-automation)!: a whitespace-only `config.condition` is refused at `registerFlow`, the rule the edge door has carried since #15807 (#17322) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): a flow + node's `config.condition` — a `decision` node's predicate, and on a `start` node + the **trigger gate** — is now refused at `registerFlow` when its source is blank + after trimming, where it used to register clean and answer a **silent `false`** + at every evaluation. + + Two doors, the same authored value, two fates until now. `FlowEdgeSchema.condition` + composes `EvaluatedExpressionInputSchema` (#15807), so `' '` on an edge is + refused at `FlowSchema.parse`, by name. A node's `config` is an open + `z.record(z.string(), z.unknown())`, so the same value passed through verbatim, + reached `AutomationEngine.evaluateCondition`'s empty-source arm — `exprStr.trim() + === ''` — and returned `false`, under a comment that names that arm as being for + an **unauthored** condition. `' '` was authored. The branch never ran, forever, + with nothing said at any layer. + + ```yaml + nodes: + - { id: gate, type: start, config: { objectName: lead, triggerType: record-after-update, condition: ' ' } } # the flow was gated shut + - { id: branch, type: decision, config: { condition: { dialect: cel, source: ' ' } } } # the same blank, through the envelope key + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form of phase M9.1) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **The rule is imported, not re-derived.** `registerFlow`'s structural pass runs + the condition's source through `EvaluatedExpressionInputSchema` itself, so the + node door and the edge door cannot drift into two notions of "blank" or two + sentences for it — the property the #15662 campaign built the shared refusal + for. Nothing is exported from this package to carry it, and no new export was + added. + - **Applied to the SOURCE, not to the whole value**, deliberately: the union + would also refuse an envelope with no `dialect` or with a dialect outside its + enum, and this slot admits both (`structuralConditionRefusal`'s docblock, + #4336). The narrowing is exactly the blank population and nothing else — a + `cron` envelope with a real source still earns its own pre-existing verdict, + and a bare string with a `{…}` brace trap still earns #1491's. + - **`evaluateCondition` is unchanged and still answers `false`.** It is the + shared evaluator and a public method on an exported class, so its throw + behaviour is itself a contract; and a stored flow reaches it whatever the + producer refuses. This change is at the producer only. + - **`structuralConditionRefusal` is unchanged.** A string is still a well-shaped + condition; the new refusal sits behind the shape one and in front of the CEL + one, and answers the evaluated-slot sentence rather than + `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. + + **What an author does with a refused condition.** A whitespace-only condition was + never a predicate — the engine answered `false`, so the branch never fired, and on + a `start` node the flow never triggered. **Remove the `condition` key** if the node + was meant to be unconditional, or **write the expression** if it was meant to + branch. ⚠️ Those two are not interchangeable: a refused condition never fired, + while an absent `condition` on a decision node is an unconditional branch that + always fires and an absent one on a start node is a gate that always opens. + Deleting the key to clear the refusal inverts the node rather than preserving it. + Every condition with a non-blank source is unchanged, and nothing is renamed or + retired. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole flow, + not just the branch.** Stored flows are deliberately not canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`); they canonicalize + at `registerFlow`, and each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So a node condition that used to answer a + silent `false` while the rest of the flow ran now takes the flow down with it: it + is never registered, its trigger is never armed, and the announcement is that one + warn line — `[Automation] failed to register flow` at boot, `[Automation] + cold-boot flow bind: failed to register flow` at the kernel:ready bind, + `[Automation] flow re-sync: failed to register flow` on a re-sync. The warn line + is also the locator: the refusal names the node and the slot, e.g. `node 'gate' + (start) condition`. A stack authored in config files has a second door, + `objectstack validate` — see the note below for what that door does **not** yet + say. + + **A repo-wide census on this branch found zero authored `config.condition` values + of this shape**, against a lit control: a textual probe over all 8,123 tracked + source files found **461** non-blank `condition:` string literals and **zero** + blank-after-trim ones in any authored flow (the four blank hits are two prose + examples inside #15807's own changeset and two `packages/lint` test fixtures). + There is nothing in this repository to rewrite. + + ⚠️ **Two follow-ups this change does not carry, both outside this card's package.** + (1) The ADR-0087 D3 entry named above, + `flow-edge-condition-evaluated-slot-source-required`, registers the decision this + change is a second face of — an evaluated slot requires a non-blank `source` — but + its `surface` and `acceptanceCriteria` name only `edges[].condition`. They need + widening to `config.condition` so a consumer replaying the chain is told to sweep + the node key too; that file is in `packages/spec`. + (2) `@objectstack/lint`'s `validate-expressions` applies only + `structuralConditionRefusal` to a structural condition, so `objectstack validate` + still reports nothing for a blank `config.condition` that `registerFlow` now + refuses — the two doors disagree until that rule is rebound as well. +- 9540590: `restoreConsumedSuspension` reaches a NESTED run: the ancestors a stranded descendant cascade-failed are journalled too, and the chain is re-armed as one unit + + `resumeInternal`'s catch arm journalled the consumed suspension of the run that + threw, and nothing else. For a nested run the ancestors were handled on both + paths with no journal at all: up-bubble (`failAncestors` walks `$parentRunId` + and calls `failSuspendedRun` on each suspended ancestor) and delegation (the + parent frame sees a failed child with no retryable code and calls + `failSuspendedRun` on itself). `failSuspendedRun` was `forgetSuspendedRun(run, + 'failed')` plus a `failed` log record — it journalled nothing. + + So the leaf was restorable while every ancestor was recorded `failed` with its + pause consumed and no snapshot (`restoreConsumedSuspension(PARENT)` answered + `NO_CONSUMED_SUSPENSION`), and restoring the leaf completed it into a parent + that never continues: `bubbleToParent` found no parent suspension and logged. + The operator ended up worse off than before using the exit. + + `failSuspendedRun` now journals the pause it consumes whenever the descendant + whose failure consumed it is itself repairable — from the same single producer + and onto the same durable terminal row as the strand's own snapshot, so the + chain is repairable from any replica and after a restart, not only from the + process that stranded it. `restoreConsumedSuspension` then repairs the chain as + one unit: it walks down to the stranded descendant and up through the ancestors + it cascaded into, and re-arms every member DEEPEST FIRST, so an ancestor becomes + resumable only after the run it is parked awaiting is parked again. The entry + point does not matter — naming any member of the chain repairs all of it — and + the continuation is then re-issued once, on the run that was named. + + Additive on the wire and in the type: the result's existing fields still + describe the run the caller named, and the new `chain` key is present only when + the repair was a chain repair. `ChainRestoreEntry` is exported for it. The + narrower `IAutomationService.restoreConsumedSuspension` contract in + `@objectstack/spec` is unchanged and the HTTP door's payload is unchanged — the + door answers `{ runId, restored, reason }` as it always did. + + Every member goes through the same per-run call as a flat restore — its own + in-process claim, its own strict live-suspension read, its own two-witness read, + its own durable park — so idempotence and the #14333 advance claim hold per run + in the chain: a second restore finds every member parked and answers + `RUN_SUSPENDED` without minting a second pause anywhere. + + ⛔ No ancestor is stamped `'stranded'`. That word is the resume result of a run + that consumed its OWN pause and then threw downstream, and nothing re-arms an + ancestor by resuming it; stamping it would send an operator to retry a recovery + that cannot succeed. The parent frame's delegation result still carries no + status at all, and an ancestor's repairability is carried by the journal and by + this verb's answer. + + Journalling is EARNED, not applied to every cascade: an ancestor whose + descendant is beyond repair is still consumed without a snapshot, because + re-arming it would promise a chain repair that could not be completed. + + **`@objectstack/plugin-approvals`** reports the consequence rather than causing + it: `inspectStrandedRequests` asks the engine per run, so a cascade-failed + ancestor whose descendant is repairable now comes back `runState: + 'repairable'` instead of `'unrepairable'`, and restoring either row repairs the + pair. `'unrepairable'` keeps its other causes — a run that never paused, a + snapshot no longer held, and a cascade whose descendant was itself beyond + repair. No plugin logic changed; the docblocks that documented the old + limitation did. +- 775e5ec: A run's durable history row records the terminal status the run actually reached — `completed`, `failed`, `cancelled` or `timed_out` — instead of folding all four into two. A restart no longer changes a run's answer. + + `RunRecord.status` declared two members (`'completed' | 'failed'`) while `AutomationEngine.recordLog`'s own terminal predicate admitted four and `ExecutionStatus` (`@objectstack/spec`) has declared them all along. Both ends of the store folded to match the narrower declaration: the write mapped everything that was not `completed` to `failed`, and the read mapped everything that was not `failed` back to `completed`. The distinction was therefore not hidden — it was **destroyed at write time**, so no later change could recover it for a row already stored. The cost was that one run answered differently depending on where you read it: `getRun` prefers the in-memory ring entry and said `cancelled`, while after a restart or a ring-buffer eviction the durable row answered, and it said `failed`. + + - **The write side.** `recordLog` writes the status its own terminal predicate admitted, resolved once into a `const` that also decides whether a row is written at all. The predicate is now the single declared vocabulary, `TERMINAL_RUN_STATUSES` (`engine.ts`) — three sites had a copy of that list and only one of them was ever going to be updated together with the writer. + - **The read side.** `ObjectStoreSuspendedRunStore` resolves the row's status once in the gate that already decided whether the row is terminal at all and hands the member to `deserializeTerminal`, which no longer re-reads or folds it. `listHistory`'s filter was the second copy of the two-member list — left alone it would have replaced a wrong status with a *missing row*, dropping cancelled runs out of the Runs list entirely. + - **The stored column.** `sys_automation_run.status` accepts the two added members, and the retention scope (`lifecycle.retention.onlyWhen`) counts them as terminal — a widened writer over a two-member sweep scope would have left `cancelled` and `timed_out` history rows never ageing out, on a table whose whole retention posture (ADR-0057) is that history is telemetry. `refused` is deliberately not added: `ExecutionStatus` declares it (#14945) but no engine path produces it, and an option nothing can write is declared-but-inert metadata (ADR-0078). + - **Rows already stored keep reading `failed`.** The information they lost is not recoverable and this change does not pretend otherwise — there is no backfill, because there is nothing to backfill *from*. Rows written from this release forward carry the distinction. + - **`TerminalRunStatus`** is exported for the same reason `ConsumedSuspensionDropNotice` is: `RunRecord` is barrel-reachable, and a host store implementing `recordTerminal` / `loadTerminal` has to be able to name the field it round-trips. + + Not a breaking change, and deliberately carries no breaking-change banner: the published contract (`IAutomationService.getRun` / `listRuns` return `ExecutionLog`, whose `status` is `ExecutionStatus`) has declared all four members since before this row existed. What changes is that the implementation stops under-reporting one the contract already promised — a consumer written against the declared contract is unaffected. Also no ADR-0087 migration entry: that ADR governs authorable metadata shapes on `sys_metadata`, and this is an engine-owned system data table whose existing values stay valid under the widened option set. +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. + +### Patch Changes + +- 216b066: A run whose nodes all succeeded is no longer answered `failed` — or, under `errorHandling.strategy: 'retry'`, RE-EXECUTED — because its terminal run-history write threw (#16274) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for **node** failures, so a throw out of a history write on a run that had already finished successfully was handled as though a node had thrown. This is the initial-execution half of the pattern fixed on the resume path in 17.4.0; that fix deliberately scoped these two sites out. + + **The consequence was measured, and it is a double run, not just a mislabelled one.** `execute()`'s node-failure arm ends at the retry strategy branch, which hands the false `failed` result to the retry loop; the loop reads `result.success` and therefore re-enters `executeWithoutRetry()` — the whole flow, every node, again. Driven with `maxRetries: 2`: a flow whose node always succeeded ran it **three** times and wrote three `failed` rows, unattended, inside one `execute()` call, with the node's side effects repeated each time. Controls on the same instrument: the identical flow on healthy sinks runs the node once, and a genuine node failure runs it three times (retry working correctly). + + **What can throw there is a host surface, not in-repo code** — which is why it could not be reproduced from inside the package and why the package owed the fix: + + - the run-summary line `logger.info(line, meta)`, on by default (`runSummaryLog: 'info'`) and calling a **host-injected** `Logger`. This one needs no store at all. + - `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise — the `void write.catch(...)` beneath that call only ever sees a returned promise's rejection. Both stores shipped in this package are `async` methods and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. (A store returning a non-thenable escapes identically: `write.catch` is then itself a synchronous `TypeError`.) + + On that second variant the old code did not even answer `failed`: the node-failure arm's own `recordLog({ status: 'failed' })` threw again out of the same store and escaped `execute()` entirely — a rejected promise where `AutomationResult` is declared. + + What changes: + + - **Each completion-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller is told the truth — `success: true`, no `status`, the flow's `successMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first — the node runs exactly once, and one `completed` row is recorded rather than `1 + maxRetries` `failed` ones. + - **The swallowed failure is reported once per run at `error`**, with the consequence and the fix in the first line: the run completed, its terminal history row never landed, nothing retries it, and the run must not be re-run. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: a genuine node failure still reaches the node-failure arm, is still recorded `failed`, still carries the node's own text, and is still retried the full `1 + maxRetries` times. +- bea41f6: A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562) + + `AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0. + + **What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's. + + Reproduced with a control, the identical flow and the identical node failure differing only in the store: + + ``` + store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} + store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} + ``` + + **What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained. + + What changes: + + - **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first. + - **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says. + - **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot. + + ⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before. +- a36b526: `sys_automation_run.variables_json` states its presence discriminator in ONE direction, and a row-rebuilt snapshot no longer claims its steps are the pause's + + Three corrections to text this package ships. No behaviour changes; every shape + described below is the ruled design, measured as it already is. + + **`variables_json` said `⇔` where only `⇒` holds.** The field description + declared "present on a completed/failed row" and "the row's run had a pause its + resume consumed before a downstream node failed" to be equivalent. The forward + direction holds — nothing but the consumed-suspension path writes that column on + a terminal row. The reverse does not, for one shape: a run that stranded, was + restored and then finished. `recordTerminal` upserts the SAME `run_` row + with all four snapshot columns explicitly `null` — deliberately, so + "restorable" cannot outlive the condition it describes — which leaves that row + equal, across every column the discriminator is read from, to the row of a run + that never paused at all. Absence means "nothing to restore now", never "this + run never had one", and the restore verb already refuses in exactly those terms: + it names the status it observed and declines to say which. The description now + says so. + + **A snapshot rebuilt from a row does not carry the step log as of the pause.** + `deserializeConsumedSuspension`'s docblock said its `steps` are the log "AS OF + THE PAUSE". That is true of the engine's process-local journal copy only, which + slices `run.steps` back to the step count at the pause; the trimmed array is + never persisted. `steps` are the one field the rebuild takes from the row's own + `steps_json`, which is the terminal row's log of the WHOLE run — and both bounds + on that column keep the failure on purpose (history compaction retains every + failure; the byte cap trims the head). A row-rebuilt snapshot therefore carries + steps the pause did not have. It re-arms the same run regardless: the pause is + `nodeId` plus `variables` / `context` / `correlation`, none of which the step log + feeds. + + **`recordTerminal` now names the verb that reads what it writes** — the + restore path in `engine.ts` — and the three properties of the write that are + that verb's inputs rather than local detail. Its summary line also said + "completed / failed" where the terminal vocabulary has had four members since + the fold was removed from both ends of this write. + + Both falsifying shapes are pinned in `suspended-run-store.test.ts`, including the + indistinguishability itself: the restored-then-finished row and a never-paused + row compare equal across those five columns, with the same comparison separating + them while the snapshot is still there. +- ae6dcf6: `notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify + + A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line. + + `emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible. + + The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy. + + The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in. + + `selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back. +- a2509d7: fix(service-automation): a `null` / `undefined` envelope is refused attributed, not as a raw `TypeError` (#16439) + + `AutomationEngine.evaluateValueEnvelope` derives its verdict from `valueEnvelopeRefusals` — the same call `registerFlow` makes — so registration's reject set and evaluation's reject set are one set by construction. That covered every malformed **envelope**, and exactly two shapes fell outside it: `null` and `undefined`. Neither published primitive judges them (the shape rule is a no-op on anything not `isExpressionEnvelopeShaped`, and `validateExpression` reads an absent `source` as "not authored"), so both returned no findings and the method went on to read `envelope.source` off nothing — `TypeError: Cannot read properties of null (reading 'source')`, with no `where`, no source and no rule. Driven across the ten shapes the card enumerates, eight failed attributed and only these two did not. + + Both now fail attributed like the other eight, led by the published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` sentence and carrying the `where` and the source. The rule is stated in the **shared** refusal, never as a guard in the evaluator: a reject reason living only on the evaluation side would end the very property this design has. + + Refused rather than admitted, and the asymmetry with the predicate path is deliberate: `structuralConditionRefusal` admits `null` / `undefined` because the condition *field* is optional, so absence there means "the author wrote no predicate". A value slot's envelope **is** the value, so an absent one is a caller handing nothing where a value was required. + + **Why `patch`, not `minor` and not nothing.** Nothing changes for authored metadata: the only production call site guards with `isExpressionEnvelopeShaped`, which neither shape satisfies, and the value-role feeder emits only envelope-shaped objects, so `registerFlow` never presents a nullish value to the shared refusal — measured, and pinned. An authored `null` in an `assignments` slot is still a literal, still parses and still registers. What does move is the runtime behaviour of a **public method on an exported class**: a direct caller that passed a nullish envelope used to get a language-level `TypeError` and now gets an attributed `Error`. That is a published surface, so it is not silent — but it adds no API, no option and no capability, and no correct caller has to adapt, which is what makes it a patch rather than a minor. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [5505646] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/metadata-core@17.5.0 + - @objectstack/formula@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 4d387a6be3..568e38652e 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.4.0", + "version": "17.5.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 10b104a812..4fe54e18e3 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,78 @@ # @objectstack/service-cache +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 3df43d3cb2..07a3bfa15c 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.4.0", + "version": "17.5.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 5469fb2b69..496fbcfaf2 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,73 @@ # @objectstack/service-cluster-redis +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-cluster@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index a96d1985f9..4bbcfcd8c2 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.4.0", + "version": "17.5.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 2184ca65b4..54e667a332 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/service-cluster +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index e868357c6e..5395f0d1eb 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.4.0", + "version": "17.5.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 95bf060b58..d943473728 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,146 @@ # @objectstack/service-external-datasource +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [82cb69f] +- Updated dependencies [2eb4724] +- Updated dependencies [d46deba] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [3cbcedb] +- Updated dependencies [3cbcedb] +- Updated dependencies [bdea10a] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [77c801e] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [555a89c] +- Updated dependencies [b90aff8] +- Updated dependencies [0f38ab0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/driver-sql@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/driver-memory@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/driver-turso@17.5.0 + - @objectstack/driver-sqlite-wasm@17.5.0 + - @objectstack/driver-mongodb@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 3951961fe2..d217927d3f 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.4.0", + "version": "17.5.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 799e3fbaad..19906f66bf 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,82 @@ # @objectstack/service-i18n +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 9a906c141a..2fc00c05c5 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.4.0", + "version": "17.5.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 67420b72cf..6610963d8b 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/service-job +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 5b347a805d..4318d70992 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.4.0", + "version": "17.5.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 74def67373..e1e247ffe5 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/service-knowledge +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 53bc307d47..c85ef7d257 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.4.0", + "version": "17.5.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-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 7144589d70..a7f8c251c9 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,125 @@ # @objectstack/service-messaging +## 17.5.0 + +### Minor Changes + +- 690f083: `NotificationDispatcher` reaps once per tick instead of once per claim, backs off while the outbox is idle, and `emit()` wakes it (#17610) + + **What an idle dispatcher cost.** Against an EMPTY `sys_notification_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` and `claimDigest()` in each — and each of those opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **32 statements a tick, 16 of them the identical reap UPDATE**, on a fixed 500 ms interval that never let up, one loop per warm kernel. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + 2 × partitionCount` = 17 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s; `MessagingServicePlugin` option `dispatchMaxIdleIntervalMs`). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks instead of 1,201. + - **`emit()` wakes the dispatcher.** `MessagingService.setOutbox(outbox, { onEnqueued })` fires once per `emit()` that enqueued at least one delivery; the plugin points it at the new `NotificationDispatcher.wake()`, which ticks immediately — or once more, right after a tick already in flight. + + **Latency bound.** A notification emitted in the process that runs the dispatcher goes out on the tick `wake()` starts, no later than before. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): a deferred delivery coming due (retry schedule, quiet hours, digest window), a row enqueued by a process that does not run this dispatcher, and a crashed node's expired claim (recovered within `claimTtlMs` + `maxIdleIntervalMs`). Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `INotificationOutbox` gains an optional `reap(opts: ReapOptions)` — the visibility-timeout recovery `claim()` / `claimDigest()` already open with, as a method of its own — and `ClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlNotificationOutbox`, `MemoryNotificationOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost; implementing `reap()` and honouring `skipReap` is what earns the once-per-tick cost. Direct callers of `claim()` / `claimDigest()` are unaffected: without `skipReap` they reap exactly as before. Also new: `NotificationDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, and `MessagingService.setOutbox`'s optional second argument. +- a9096af: `HttpDispatcher` reaps once per tick instead of once per partition, backs off while `sys_http_delivery` is idle, and `enqueueHttp()` / `redeliverHttp()` wake it (#17623) + + **What an idle dispatcher cost.** Against an EMPTY `sys_http_delivery` outbox every tick walked `partitionCount` partitions (default 8) and ran `claim()` in each — and each claim opened with the environment-wide visibility-timeout reap before its candidate SELECT. Measured on a real `ObjectQL` + `SqlDriver`: **16 SQL statements a tick, 8 of them the identical reap UPDATE**, on a fixed 500 ms `setInterval` that never let up, one loop per warm kernel. It is the shape #17610 removed from `NotificationDispatcher`, still running beside it. On remote Turso every statement is an HTTP round trip. + + **Now:** + + - **The reap runs once per tick**, before any claim — an idle tick is `1 + partitionCount` = 9 statements. Its predicate names no partition, so one run returns every claim that had expired when the tick began; a claim that expires during the tick is returned by the next one. A crashed node's `in_flight` rows are still recovered within one tick of `claimTtlMs` passing, and a claim is still never re-taken before its TTL. + - **The loop backs off while idle.** Every tick that claims nothing doubles the delay to the next, from `intervalMs` up to `maxIdleIntervalMs` (default 30 s, the notification dispatcher's default). A tick that claims work snaps back to `intervalMs`. With the defaults, ten idle minutes are 24 ticks and 216 statements instead of 1,201 ticks and 19,216. + - **`MessagingServicePlugin`'s `dispatchMaxIdleIntervalMs` sets the ceiling for both dispatchers**, the way `dispatchIntervalMs` and `partitionCount` already govern both. + - **Writes in this process wake the dispatcher.** `MessagingService.setHttpOutbox(outbox, { onEnqueued })` fires after an `enqueueHttp()` that enqueues a delivery — not one that parks an undeliverable record, which is `dead` on arrival — and after a `redeliverHttp()`. The plugin points it at the new `HttpDispatcher.wake()`, which ticks immediately, or once more right after a tick already in flight. + + **Latency bound.** A delivery enqueued or redelivered in the process that runs the dispatcher goes out on the tick `wake()` starts. While idle, work nobody announces is noticed within one backed-off interval, at most `maxIdleIntervalMs` (30 s by default): + + - a retry coming due is attempted less than `min(its delay + intervalMs, maxIdleIntervalMs)` late, because the backoff restarts from `intervalMs` at the attempt that scheduled it; + - a row enqueued by a process that does not run this dispatcher; + - a crashed node's expired claim, recovered within `claimTtlMs` + `maxIdleIntervalMs` (about 35 s at defaults, where it was about 5.5 s). + + Set `dispatchMaxIdleIntervalMs` to `dispatchIntervalMs` to keep the fixed interval. + + **Contract additions — all optional, nothing to change on upgrade.** `IHttpOutbox` gains an optional `reap(opts: HttpReapOptions)` — the visibility-timeout recovery `claim()` already opens with, as a method of its own — and `HttpClaimOptions` gains an optional `skipReap`. Both built-in stores (`SqlHttpOutbox`, `MemoryHttpOutbox`) implement them. A custom outbox without `reap()` keeps working as it is: the dispatcher probes for the method and, when it is absent, lets each claim reap as before — correct, at the old per-claim cost. Direct callers of `claim()` are unaffected: without `skipReap` they reap exactly as before. Also new: `HttpDispatcher.wake()`, the dispatcher's `maxIdleIntervalMs` option, the `HttpReapOptions` type, and `MessagingService.setHttpOutbox`'s optional second argument. + + **One loop, not two copies.** The timer loop — idle backoff, collapsing wakes into one follow-up tick, `stop()` — moved out of `NotificationDispatcher` into a module both dispatchers share. `NotificationDispatcher`'s behaviour and public surface are unchanged; its #17610 tests pass as they were. +- 4be4e04: `IHttpOutbox.ack()` takes an optional third argument, the claim credential, and `HttpDispatcher` now always passes it (#17634). A late ack from a claim the visibility-timeout reap had taken back — a send that outran `claimTtlMs` while another dispatcher re-claimed the row — used to write its outcome by row id over that dispatcher's live attempt: a delivery still in progress could be marked `dead`, or one attempt's outcome overwrite another's. Handed the credential, `SqlHttpOutbox` and `MemoryHttpOutbox` perform the compare-and-set `INotificationOutbox.ack()` has performed since #11859: the outcome is written only while the row is still `in_flight` under the same (`claimedBy`, `claimedAt`) pair `claim()` stamped on it. A lost claim writes nothing and throws the new `HttpAckError` (`DELIVERY_NOT_ELIGIBLE`, the code this package already raises for a delivery row in the wrong state); the dispatcher logs `http-dispatcher: ack refused, claim no longer held`, carries on with the rest of its batch, and whoever holds the row re-drives the delivery. + + Nothing written against the two-argument `ack(id, result)` has to change. An `IHttpOutbox` implementation that does not read the third argument compiles and works as before, and a caller that does not pass it gets the by-id write it always got — that arity is deprecated, because it checks no ownership. New exports: `HttpClaimCredential` and `HttpAckError`. A subclass that overrides a built-in store's `ack()` should forward the third argument to `super.ack()`, or its dispatcher acks keep the old unchecked write. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index ba231c205a..7c21a4cb8b 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.4.0", + "version": "17.5.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 9100c037c2..064a0308fd 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,80 @@ # @objectstack/service-package +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [cca1dc0] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/metadata-core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 3af381fdcd..7a0f1dec18 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index ea83913bab..e79504ff2f 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/service-queue +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index fd7f8ecd93..59ba6d7bf8 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.4.0", + "version": "17.5.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 9089ad39e3..af51a4df84 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,79 @@ # @objectstack/service-realtime +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 6de40730c7..573f810743 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.4.0", + "version": "17.5.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 7c756981ad..160f479fba 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,133 @@ # @objectstack/service-settings +## 17.5.0 + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index ff1cbda03e..928d29d057 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.4.0", + "version": "17.5.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 84b1d5ad0e..bf383ee259 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,86 @@ # @objectstack/service-sms +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [dd2fd20] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [45c2cf9] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [9ca49eb] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 4acf73ea57..a47e1a83ed 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.4.0", + "version": "17.5.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 3b943e6fbe..bd04213a7e 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,159 @@ # @objectstack/service-storage +## 17.5.0 + +### Minor Changes + +- 6ff5b56: **Clause-②: yes** — a new REQUIRED member on two published option types (`S3StorageAdapterOptions.keyPrefix`, and the `s3` member of `StorageServicePluginOptions`), so the accept set a consumer writes against narrows. Contract-review tier. + + **BREAKING** — `S3StorageAdapterOptions` and `StorageServicePluginOptions.s3` now require `keyPrefix: string | null`. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. + + The **S3 adapter can now be confined to a key namespace**, and the confinement is structural rather than conventional: a caller holding the adapter has no door through which it can reach an unprefixed key. + + `keyPrefix` is applied on `upload` / `download` / `delete` / `exists` / `getInfo`, on both presigned doors, on every multipart door, and into `list()`'s `Prefix` — and it is stripped off every key and every `list()` cursor coming back. Callers therefore supply and receive unprefixed keys at every door, in both directions, and `list('')` enumerates this adapter's namespace and nothing else. Keys are concatenated, never path-joined, so a caller key such as `../elsewhere` stays a literal key inside the namespace instead of escaping it. + + **Why it is required rather than optional.** A shared bucket with no namespace has one thing keeping one deployment out of another's objects: that every `sys_file` metadata check above the adapter was written correctly. On a route that takes an identifier out of a request, one missed check is a cross-deployment read the object store cannot refuse, because what it sees is a well-formed key. An optional prefix reproduces exactly that gap the first time a host forgets to set it, silently — so the choice is made at the call site or the code does not compile. `null` is the written, greppable way to ask for bucket-root keys, and it produces byte-identical keys to those written before this option existed. + + For the same reason an empty or whitespace-only string is **refused at construction** rather than treated as "no prefix": that is what an unset environment variable looks like after interpolation. A leading `/` and any `..` segment are refused too, and a missing trailing `/` is appended — the last of those is load-bearing, not tidiness: S3 `Prefix` is a raw string match, so `tenant_1` without the delimiter also matches `tenant_10/...`, and one namespace would enumerate its neighbour through the isolation mechanism itself. + + Two further seams move with it: + + - `StorageServicePlugin` carries the **host's** namespace onto every adapter a `storage` settings re-read rebuilds, and deliberately reads no prefix out of the settings values. A boundary an administrator inside the deployment can set or clear is a preference, not a boundary; without this, one settings save returned a hosted deployment to a shared, unprefixed key space. A host that declared no `s3` constructor options expressed no namespace, and settings-configured S3 stays bucket-root as before. + - `resolveStorageTarget` puts the namespace in the target's **`location`**, not merely its fingerprint: two prefixes in one bucket are two disjoint object sets, so moving the prefix strands what the old one held exactly as moving the bucket does, and the swap must print the migration warning. `env_7` and `env_7/` normalise to one target, so the same namespace spelled two ways is not read as a move. + + `LocalStorageAdapterOptions` is deliberately unchanged: `resolvePath()` already refuses any `..` and joins every key under `rootDir`, so the local adapter's containment boundary exists and a second mechanism would be two ways to say one thing. + + **Migrating:** every `new S3StorageAdapter({ ... })` and every `new StorageServicePlugin({ adapter: 's3', s3: { ... } })` gains one member. Single-tenant deployments write `keyPrefix: null` and their keys do not move. Deployments sharing a bucket write the namespace they want and should treat the change as a store move — existing objects are not migrated into the new namespace. + + + +### Patch Changes + +- 71629a1: refactor(core): one `classifyAdmissionTenancyPosture`, so six admission seams cannot each get the classification wrong (#16013) + + Six admission doors each hand-wrote the same try/catch on the `tenancy` read that + feeds `resolveAuthzContext`: the registry's branded "never registered" rejection + (`isServiceNotRegisteredError`, #13905) resolves quietly to `undefined` — the + supported no-tenancy composition, where no posture-conditional refusal runs at + all — and every other rejection becomes `AuthzStoreUnavailableError('tenancy', err)` + (ADR-0112 `SERVICE_UNAVAILABLE` / 503), because the posture is an authorization + INPUT and admission was therefore never DECIDED. That is #13906 decision 1 + option A, and it is the part nobody may get wrong: a quiet `catch` at any one of + the six re-opens the defect, where a failure reads as "this check does not apply" + and an ex-member's org-stamped API key is admitted. + + Nothing is broken today — every copy was correct — so this removes a standing + hazard rather than fixing a defect. **No admission verdict changes**, on any + wiring: the classification is byte-for-byte the decision the six copies made, + now made once. + + - **`@objectstack/core` gains `classifyAdmissionTenancyPosture`** (and the + `TenancyServiceResolver` type), exported from the package index beside + `effectiveTenancyPosture`. It takes a THUNK and owns the classification only. + The thunk is not a style choice: the REJECTION is what gets classified, so the + resolution has to happen inside the helper's `try` — a caller that awaited the + service first would need a `catch` of its own, which is the thing being + deleted. + - **The RESOLUTION deliberately did not move.** `rest-server.ts` branches on + kernel-vs-provider, and asking twice would let a provider bound to the local + kernel answer for a request that resolved to another environment; four seams + read `ctx.getKernel()`; `service-storage` reads an already-normalised gate + registry; and each seam's reason why a MISSING async accessor must stay quiet + is its own argument (the storage door's is its declared degrade-to-ungated + contract, the others' is the `KernelBase`/`LiteKernel` host shape). A helper + that also owned how the service is reached would be wrong for one of them or + grow a flag per seam — the copies again, with an extra step. Every one of + those reasons stays written at its seam. + - **Folded**: `packages/rest/src/rest-server.ts` (both wirings), + `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, + `packages/plugins/plugin-sharing/src/sharing-plugin.ts`, + `packages/services/service-datasource/src/admin-routes.ts`, + `packages/services/service-settings/src/settings-service-plugin.ts`, + `packages/services/service-storage/src/storage-service-plugin.ts`. + - **Pinned where the decision now lives**: + `packages/core/src/security/admission-tenancy-posture.test.ts` drives both + rejections at the production seam — a real `ObjectKernel` that never + registered `tenancy`, and one whose `tenancy` factory throws — each beside the + brand predicate's own answer on that same rejection, so "the outage throws" is + distinguishable from a helper that throws at everything. It also holds the + constraint mechanically: the helper's source may not name an accessor, a + kernel or a plugin context, and it takes exactly one parameter. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/observability@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index d85b4a1f68..43ed6fbcb0 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.4.0", + "version": "17.5.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/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index d94ff3b4b5..16f7e6a41d 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,2373 @@ # @objectstack/spec +## 17.5.0 + +### Minor Changes + +- 7382c5d: feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) + + Two halves of one vocabulary defect, and only one of them is a narrowing. + + **BREAKING** — a bare `element:filter` / `element:form` component node no longer + parses. Both elements were retired whole at element grain (ADR-0049 + enforce-or-remove): no renderer for either ever shipped in objectui, framework + or cloud. Every authorable key became a `retiredKey` tombstone at the time, but + the node itself kept parsing, and each schema's own docblock recorded that as a + limitation rather than an intention: + + > A bare node with empty `properties` parses clean (the open `type` union + > accepts any string, so a node-level refusal is not expressible here) + + It is expressible one level up. Both names join + `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with + a located prescription — the same door already built for `user:profile`. + + ``` + FROM PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: true } // nothing renders it; the console + // drew the unknown-type panel + + TO PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: false, + issues: [{ code: 'custom', path: ['type'], + params: { retiredComponentType: 'element:filter' }, + message: '`element:filter` was removed in @objectstack/spec 17 …' }] } + ``` + + **The prescription is not new prose.** Each node message is the element-grain + TAIL of that element's own `retiredKey` tombstones with the `property ` + clause dropped, so the node door and the props door carry one text — pinned + byte-for-byte in `component.test.ts`. An author who writes `element:filter` is + told to delete the component and use a view's `userFilters` quick-filter bar or + the list toolbar's filter builder; an author who writes `element:form` is sent + to the object-bound `object-form` block. + + **What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one + would demote a loud retirement to a silent skip on every reader that dispatches + on it — so both rows keep refusing each retired key with its own per-key + prescription, and `isKnownComponentType` still answers `true` for both. The open + string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and + every live `element:*` member parse exactly as before. The two D2 conversions + still strip the keys and still leave the node; what changes is that the node + they leave is now refused by name instead of sitting inert, and their prose says + so. + + **The other half is a plain bug fix, no accept set involved.** + `KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the + `component-type-unknown` authoring rule — was derived from every known type, + retired ones included. Measured through the rule: + + ``` + FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." + TO type: 'element:fitler' -> hint: "Use a declared component type from the standard + vocabulary, or … give it its own namespace …" + ``` + + The tool was renaming an author INTO a retired element — a rename the parser + refuses. The pool is now the known set minus whatever the vocabulary retired, + derived from the retirement map rather than restated beside it, so a type + retired tomorrow leaves the pool the day it lands. Live spellings are + unaffected: `global:serch` still proposes `global:search`, `record:detials` + still proposes `record:details`, `element:butotn` still proposes + `element:button`. + + Also corrected: the vocabulary docblock described the `ComponentPropsMap` row + set as a superset of the enum by "exactly" the string-arm registrations plus the + two tombstoned elements — one member short since `user:profile` joined it. + + +- ea2940d: fix(spec): `ActionEngineFacade.delete` declares the id ARRAY the runtime has always accepted, and says which convention is the contract (#15117) + + `delete(object, id: string)` declared one id. The runtime facade + (`buildActionEngineFacade` in `packages/runtime`) has accepted `string | string[]` + all along — normalising the argument and issuing one `ql.delete` per id — and + described that in a comment as a tolerance two handler suites happened to cause. + The declaration was simply behind the behaviour, and the one first-party suite on + the array form could only reach it by hand-rolling a private copy of the + interface (a copy that had already drifted on `find`). + + The slot is now `delete(object: string, idOrIds: string | string[])`, and the + member's doc comment states the contract instead of leaving it to be inferred + from a runtime comment two packages away: + + - **Both spellings are contract.** One row is `delete(object, id)`; a set is + `delete(object, ids)` — a handler holding a list does not have to unroll it + into a loop to stay on the contract. + - **The array form is a convenience over the same per-row path** — not a bulk or + atomic delete. There is no transaction around the set: a failure part-way + leaves the ids before it deleted. An empty array deletes nothing and resolves. + + Nothing is removed and nothing narrows: every existing single-id call still + type-checks, and no runtime behaviour changes — this release makes the published + type describe what was already being served. That makes it non-breaking, not a + patch: widening a published parameter is a purely additive widening of a public + surface, which takes at least `minor` whatever the commit type says. Handler authors who copied the + facade into a local context type to reach the array form can delete the copy and + annotate with `ActionHandlerContext` / `ActionHandler` from `@objectstack/spec/ui`. +- 5f392f0: feat(spec): the ADR-0112 error envelope gains a producer-side `refusal` declaration, so a deliberate 5xx refusal can keep its caller-authored `message` (#16335) + + `ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key, **`refusal: true`** — the producer's declaration that the 5xx it named is a deliberate REFUSAL whose `message` is authored for the caller, so the boundary keeps that message verbatim instead of withholding it. Director ruling, decision batch #58 (2026-09-06, option C): the refusal/fault distinction is a producer-side declaration on the published envelope — not a status heuristic and not a second allow-list. + + The three cases are now documented side by side on the envelope's TSDoc: + + - **undeclared 5xx** (no `status` on the throw) — unchanged: the leak heuristic decides per message. + - **declared fault** (`status >= 500` + `code`, nothing declared here) — unchanged, and still the DEFAULT: `message` is withheld from the body and logged for the operator. + - **declared refusal** (`status >= 500` + `code` + `refusal: true`) — new: `message` is kept verbatim, bounded exactly as a 4xx message is. + + Purely additive: a producer that says nothing here gets exactly the previous behaviour. `true` is the only value — `refusal: false` fails parse instead of becoming a third state consumers would have to interpret. `userMessage` is orthogonal (end-user text; it never replaces `message`) and may ride the same envelope; the TSDoc reconciles this flag with the recorded reason `userMessage` is a text-carrying field rather than "a boolean beside `message`". + + This is the spec half. The relay half — the three withhold arms reading the declaration (two in `@objectstack/rest`: `declaredServerFaultAnswer`, and `resolveErrorResponse`'s own 5xx passthrough arm, which the `/references` door reaches; one at `@objectstack/runtime`'s dispatcher exit, `errorResponseBase`, which `objectstack serve` mounts and which never consults the first), plus retiring the route-local patch from PR #16143 on `/meta/:type/:name/references` — is #16146 for the REST pair and its sub-issue #17153 for the runtime exit; until they land, a declared refusal is still withheld at the wire. +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 929d9e3: feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) + + + + **BREAKING** — seven authorable positions across five schemas are DELETED. Executes the + maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family + recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove, by the + route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no + `retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. + + Seven positions declared a `CronExpressionInputSchema` slot that the parse normalized into + the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the ADR-0058 D7 + ledger row `cron-declared-unwired` had every one of them `unevaluated`. + + | family | schema | deleted position | reachable from a stack manifest | + |:--|:--|:--|:--| + | export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | + | flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | + | connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | + | cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | + | backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | + + **What an upgrading author actually observes.** None of the five schemas is `.strict()`, so + a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and + still loads, and the value is discarded there without a word. There is nothing for + `objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value + was already inert before this change, and it is inert after. + + The parse is not the only channel, and the two that speak are worth stating exactly, + because a reader who stops at "non-strict schema" will conclude the opposite: + + - **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack + manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports + `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its + value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` + prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The + channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry + schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning + as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack + refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists + nothing, in either direction. + - **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, + `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an + excess-property error at the key and deletes it. + + The other six positions are not reachable from a stack manifest, so no CLI walk visits them: + for those the parse-level strip really is the whole of it. + + **What stays, byte-identical:** every other key of the five schemas and every export — no def + leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep + their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and + `nextRunAt`, and a state without `cronExpression` now parses (the requiredness left with the + key); `CacheWarmup.strategy` keeps its `scheduled` member — a value, not a position the + ruling names, and exactly as inert as before. + + **One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required + `cronExpression: string` member is deleted from `ScheduleExportInput` in + `contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a + published runtime TS interface (both names are in `api-surface/contracts.json`). It follows + the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an + input demanding the key would ask a provider for a cadence it cannot store. The interface, + the method and every other member stay. Measured blast radius: no source outside + `packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo + (positive control: a symbol of the same class resolves outside `packages/spec` in the same + sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An + implementor that *does* exist off-tree drops the member from its object literal; a caller + constructing a `ScheduleExportInput` drops it from the literal it passes. + + **Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by + `croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` + (experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not + retired, on its sibling card). + + ## This change states no before/after rewrite, because there is none + + A breaking changeset in this repo normally states the old spelling beside the new one. + This one has no such pair to state: the same document PARSES before and after, the value + was inert in both, and no conversion can be written for it — so a metadata upgrader has no + edit to make and `os migrate meta` has nothing to list. That is a statement about the + migration chain, not about silence: `os validate` / `os build` do name the dropped + connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and + the line for a TypeScript author. What follows is guidance for authoring a cadence going + forward, not a rewrite of an existing document. + + ## What to write instead + + There is no replacement on any of the five schemas: no export scheduler, flow-state + scheduler, connector-sync scheduler, cache-warmup engine, backup engine or DR-test runner + exists to declare a cadence to. The one cron slot the platform evaluates is + `Job.schedule.expression` (`system/job.zod.ts`) — work on a cadence is a `job` whose handler + you write: + + ```ts + // A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares + // the cadence as a job instead; the handler drives the connector. + defineStack({ + connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], + jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], + }); + ``` + + The retirement kit, in the shape the 2026-09-10 ruling prescribes: + + - the key is DELETED at all seven sites (`api/export.zod.ts` ×2, + `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, + `system/disaster-recovery.zod.ts` ×2). Each site keeps a source comment recording what + left, why nothing ever read it, and what does work instead + - **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 + conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is + the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector + family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 + - the four baseline rows that existed (`automation/ScheduleState:cronExpression`, + `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, + `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, + each carrying the #4650 proof the build computes for itself: the def is not reachable from + the 26 metadata-type roots. The three nested positions never had a row of their own + - no liveness-ledger row: none of the five schemas is an enrolled ledger type + - the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row (every + position it covered is gone, so discovery by roster name no longer sees them); the cron + dialect is now exactly the one evaluated slot plus the one experimental-by-design slot + - pin tests (`cron-typed-positions-retirement.test.ts`): per site, the authored value is + accepted and stripped and the enclosing block still parses, on the base schema and through + every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` + door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; + and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion + and no D3 semantic entry names any of the seven + - generated baselines and docs follow the schema: the five reference pages are regenerated, + the published `objectstack-formula` skill's `cron` row drops the retired carriers and keeps + `Job.schedule.expression`, and `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching + `syncConfig.schedule` + - `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first + ratchets def *names* and the second export *existence*; deleting keys removes neither +- c1d54db: feat(spec): a metadata-form repeater's row properties have a name — `DashboardHeaderAction` fields carry a JSON Schema `title`, and `resolveMetadataFormSchemaTitles` overlays a bundle's `metadataForms..fields..label` onto a derived JSON Schema (#16458) + + ## What was wrong + + The Studio property panel renders `dashboard.header.actions[]` as a table whose + column headers read `items.properties[k].title ?? k` from the JSON Schema + derived by `z.toJSONSchema(DashboardSchema)`. None of the four item fields + (`label`, `actionUrl`, `actionType`, `icon`) carried a `title`, so the fallback + arm ran for every locale, English included, and the maker saw machine keys. + Nothing could localise them either: the only channel, `resolveMetadataFormLabels`, + decorates the `FormFieldSpec` tree, which the table never reads. And the platform + catalogs carried `dashboard.fields.header` alone — `dashboard.form.ts` declared + no children under the composite, so `os i18n extract` emitted no + `header.showTitle` / `header.showDescription` / `header.actions` key and the + console shipped a private overlay for exactly those three. + + ## What changed + + - **`@objectstack/spec`** — `DashboardHeaderActionSchema`'s four fields author + `.meta({ title })` (`Label`, `Action URL`, `Action Type`, `Icon`), so the + derived JSON Schema names each column. New export + `resolveMetadataFormSchemaTitles(schema, type, bundle, opts)` in + `@objectstack/spec/system`: every `metadataForms..fields..label` + at any locale of the chain becomes the `title` of the node the path addresses, + stepping through an array's `items` so a repeater ROW property is addressed + as `.` (`header.actions.label`) — the same path the + extractor emits. Pure; returns the input object itself when nothing applies. + `dashboardForm` enumerates the `header` composite's children + (`showTitle`, `showDescription`, `actions` with its four row properties) with + labels equal to the schema titles, pinned equal in `dashboard.test.ts`. + The mechanism is written down in `content/docs/protocol/kernel/i18n-standard.mdx` + → "Metadata authoring forms". + - **`@objectstack/rest`** — `GET /api/v1/meta` localises each entry's derived + `schema` beside its `form`, through that overlay. + - **`@objectstack/platform-objects`** — the four generated `metadata-forms` + catalogs carry the seven new `dashboard.fields` keys, translated in `zh-CN`, + `ja-JP` and `es-ES`. + + Additive: no key removed, no accept set changed, no parsed output moved. + + `DashboardSchema.columns` deliberately still declares no `.default(12)`, and + the reason is stronger than the one #16458 assumed. The card reasoned that the + renderer already falls back to 12, which would make `.default(12)` + behaviour-preserving. Measured at objectui `origin/main` + (`packages/plugin-dashboard/src/DashboardRenderer.tsx`), it does not: a + `columns`-less dashboard is INFERRED from the widget spans — `maxSpan > 4` + yields 12 and everything else yields **4** — and the next line switches the + whole layout on that value (`hasExplicitColumns = schema.columns != null || + inferredColumns !== 4`, positioned grid vs responsive auto-flow). Declaring the + default would therefore both retire the inference and flip every auto-flow + dashboard into the positioned grid. A default that silently materialises a key + is expensive to take back, so the round stopped at the declared condition and + left the key alone; see #16458. +- 1f0b565: fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. + + ## What was wrong + + The key never failed. It failed to *order*. + + `options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. + + Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. + + So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. + + ## What it does now + + `DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. + + It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. + + The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | + | `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | + | `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | + + ⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. + + ## What the gate does NOT cover + + Stated so the change is not read as complete: + + - ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. + - **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. + - **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. + - **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. + - **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. + + ## The siblings, measured and deliberately not touched + + `stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. + + ## The other arm, deliberately not taken + + The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. +- 23aa83c: `DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + + **What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + + **Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + + **Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + + This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. +- 357f499: feat(service-analytics)!: a dataset measure whose `aggregate` its `field`'s declared type cannot carry is refused at compile time with `400 DATASET_INVALID` (#16737, compile leg of #16099) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. A dataset + measure pairing `aggregate: 'avg'` with a `Field.datetime` used to compile to + `AVG(col)` and reach the backend; it is now refused by `compileDataset` before any + query is built. Shipped as `minor` under the repo's launch-window convention for + accept-set narrowings; the hand-migration prescription is registered under protocol + major 18 as `dataset-measure-aggregate-field-type-refused`. + + The pair is judged against `AGGREGATE_FIELD_TYPE_COMPATIBILITY` — the one table + `@objectstack/spec` declared in #16353 under the director ruling of decision batch + #59 (2026-09-06, "both legs, table in spec"). ⛔ This changeset adds no rows and + restates none: the refusal reads the shipped predicate, so the contract has exactly + one statement. + + ## What was wrong + + The answer to `AVG` over a temporal column was decided by the SQL dialect rather + than by the data. Both halves measured on this card: + + ``` + -- SQLite (better-sqlite3), the canonical UTC-text storage form (#3912) + select typeof(submitted_at), submitted_at from clm_contract limit 1; + text|2026-05-19T00:00:00.000Z + select avg(submitted_at) from clm_contract; + 2025.5 <- text->numeric coercion: the average YEAR + + -- PostgreSQL 16.13 + select avg(submitted_at) from t; + ERROR: function avg(timestamp with time zone) does not exist -- SQLSTATE 42883 + ``` + + The silent half is the dangerous one, and SQLite is the default dev datasource: + `derived: { op: 'difference', of: [avg_a, avg_b] }` over two such averages returned + `-0.85` and rendered on a tile labelled "average cycle time delta" — a number + indistinguishable from a correct one. Nothing refused it at any layer: not the + schema, not `os validate` / `os lint`, not the analytics service, not the renderer. + + ## What it does now + + - `compileDataset` refuses an incompatible `aggregate` × `field` pair with + `DATASET_INVALID` / **400**, naming the measure, the field, its declared type and + the accepted set (read off the table, never restated). Nothing reaches the driver. + - It reads the declared type from the `sourceFieldMeta` a host already wires, via a + new optional `DatasetCompileOptions.declaredFieldType` probe. + - **`derived` is covered by construction.** A derived measure's `of` operands are + base measures of the same dataset, so a dataset carrying a refused base measure + never finishes compiling and no `derived` op can be handed its output — including + when the selection names only the derived measure. + - Tiered "cannot answer, do not block" like every sibling probe: no + `sourceFieldMeta`, an unresolvable field, or a `relationship.field` path (whose + column lives on a joined object) leaves the pair unjudged. + + ## ⚠️ Scope: the compile leg executes the TEMPORAL rows only + + The gate judges only a measure whose field is declared `date` / `datetime` / + `time`; a field of any other class is never handed to the predicate. The + verdict for the pairs it does judge is the table's — no row is restated — but + which FIELDS are judged is narrower than the table, on purpose: + + - **String rows** (`min` / `max` over `text`, `select`, `lookup`, + `autonumber`, …) are **not enforced here**. They are under #16785, **ruled + C**: the table itself is to be amended to accept them, because + `measureResultType` (#15768) already types those results as `'string'` and + pins them end to end. Enforcing them from this card would pre-empt that + ruling. + - **Boolean rows** are not a refusal at all any more: #16685 was ruled A and + #16750 added `boolean` / `toggle` to `sum` / `avg` / `min` / `max`, so the + table ACCEPTS them and this gate never judged them. + - The table's `sum` × `percent` row is likewise **not** executed by this leg; + `sum` over a `percent` compiles exactly as it did before. + + ⇒ The only pairs whose behaviour changes in this release are `avg` / `sum` + over a `date` / `datetime` / `time` field. The full-table leg remains #16099's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ aggregate: 'avg', field: }` | `{ aggregate: 'min' \| 'max', field: }` — a real instant of the field's own type | + | `{ aggregate: 'sum', field: }` | store the duration as a number (a computed "days open" field) and `sum`/`avg` that | + | `derived: { op: 'difference', of: ['avg_a', 'avg_b'] }` over temporal averages | fix the two operand measures; the `derived` spec itself is unchanged | + + ⭐ A duration is not recoverable from an aggregate over instants on any backend. + Where an "average cycle time" is wanted, the cycle length has to exist as a number + before it can be averaged. + + ## What is deliberately untouched + + `date` / `datetime` used as a **dimension** — grouping, bucketing, date-range + filtering — is unchanged; this is about aggregation only. `avg` over a genuine + numeric measure, `min` / `max` over a temporal one, and `count` / `count_distinct` + over anything all behave exactly as before. + + ⚠️ **Two faces stay uncovered, deliberately.** The refusal lives in + `compileDataset` and reads a `declaredFieldType` probe, so it applies only where + a host wires one: `/analytics/query` — the non-dataset face, whose measures a + Cube infers rather than an author declaring them — is NOT covered, and neither + is any other `compileDataset` caller that passes no probe (those stand down + unjudged rather than guessing). Closing those is #16099's, not this card's. + + Alongside the refusal, `service-analytics`' contradictory annotations about what a + SQLite `Field.datetime` column physically holds are reconciled to one statement — + **seven** source sites plus two test narratives, not the four the card quoted. Some + said the column holds an INTEGER epoch and ISO TEXT at once; one said flatly that it + IS an INTEGER epoch. Neither is current: since #3912 the column has ONE + storage form, canonical UTC text, with the epoch surviving only in a database not + yet converged by `backfillCanonicalDatetimes`. The fact is now stated once, on + `AnalyticsServiceConfig.coerceTemporalFilterValue`, and the other sites link to it. + No behaviour changes from that half. +- 854639b: feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231) + + + + **BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise` and now declare the answers they have always given: + + - `findOne` → `Promise | null>` + - `update` → `Promise | number | null>` + - `delete` → `Promise` + + `any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own. + + **Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused). + + The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`. + + **What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives. + + **Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here. + + **What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`: + + - `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`. + - `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise | null>` / `Promise`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`. + - `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise` / `Promise`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers. + + The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler. +- 4792049: feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A) + + + + **BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter` + (the `dataSource` binding every data-bound page component carries) and + `ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | + 'object-calendar'].filter` — shipped as `minor` under the repo's launch-window + convention for breaking changes; the migration prescription is registered under + protocol major 18 as ONE entry for the family. + + One filter orthography platform-wide (maintainer batch adjudication 2026-08-25, + verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55, + verbatim 「同意」, option A: converge family-wide). Until this release the + binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it + refused the array the consumer's own pins author at that key, and + `element:record_picker` carried two orthographies at two keys resolved through + one `??` in the renderer — while the four `object-*` doors declared `z.unknown()` + and took the record, the ObjectQL AST tuple array and the rule array alike, + silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every + other `filter` door in the map already carried; the `FilterConditionSchema` + import that existed in `page.zod.ts` for this one site leaves with it. + + Sequenced measurement-first, as the family had to be: at the objectui pin + `a472b07` the `object-metric` aggregate path posted an array `where` that + `POST /analytics/query` refused (400 on every array form, #15828), so the + converge was parked behind the pin bump #16626. At the pin this repo builds + against (`53ded82b`, objectui#7754) the adapter lowers an authored array through + `translateFilterArray` and the spec's own `parseFilterAST` sink before the + wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` / + `ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams` + lowers it; the binding's composition seam AND-combines it with the named view's + rules through `mergeFilterNodes`. Nothing on those paths parses the value + against the installed spec. + + **Migration** (`element-data-source-and-object-block-filter-rule-array` — + listed by `os migrate meta --from 17` once the protocol major is 18): a + record-form `filter: { status: 'active' }` becomes + `filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an + operator object `{ status: { $ne: 'done' } }` becomes + `[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys + become several rules (they AND); an AST tuple array + `[['owner_id', '=', '{current_user_id}']]` becomes + `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` — + placeholders and date macros are unchanged. The record form is refused at + `filter` (`invalid_type`, expected array); the tuple array is refused at + `filter.0` (expected object). The dashboard widget `filter` + (`dashboard.zod.ts`) is a different family and is unchanged by this release + (#15829); `object-grid.defaultFilters` is a different key, not named by the + ruling, and is unchanged. + + In-repo authors migrated in the same change: four spec test fixtures at the + binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint + fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`, + `ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122: + `operator` normalizes on parse, so input ≠ infer at these five schemas now). +- 53ec0b1: feat(spec)!: `FlowEdgeSchema.condition` is an evaluated slot — it composes the new `EvaluatedExpressionInputSchema`, and `structuralConditionRefusal` no longer admits an `ast`-only envelope (#15807) + + + + **BREAKING** in the accept-set sense, landing in the launch window as `minor` + (the lockstep convention: `major` is refused by `check-changeset-no-major`, and + breaking-ness is carried by this banner plus the ADR-0087 disposition): the + edge condition of a flow — `FlowEdgeSchema.condition`, the branch predicate + `AutomationEngine.evaluateCondition` runs at every traversal — now refuses at + authoring an envelope the engine cannot evaluate, where it used to parse, + register, pass `objectstack validate`, and then answer a **silent `false`**: a + branch that quietly never fired. + + Two spellings of one seam, refused by ONE rule with one sentence + (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the rule #15430 introduced for the + `assignment` value envelope): + + ```yaml + edges: + - { id: e1, source: check, target: approve, condition: { dialect: cel, ast: { kind: const, value: true } } } # `ast` only — the engine never reads it + - { id: e2, source: check, target: reject, condition: { dialect: cel, source: ' ' } } # blank after trimming + - { id: e3, source: check, target: escalate, condition: ' ' } # the shorthand for the same blank source + ``` + + > An expression in an evaluated slot needs a non-blank `source`: the expression + > engine evaluates `source` (the canonical persisted form of phase M9.1) and + > cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source` + > that is blank after trimming, would validate and register and then fault at + > run time. Write `{ dialect: 'cel', source: '…' }`. + + - **New export `EvaluatedExpressionInputSchema`** (type `EvaluatedExpressionInput`), + the sibling of `ExpressionInputSchema` for an evaluated slot: the bare-string + shorthand still normalizes to `{ dialect: 'cel', source }`, but the string + must be non-blank after trimming, and the envelope arm composes + `EvaluatedExpressionSchema` (`source` required and non-blank) instead of + `ExpressionSchema`. `FlowEdgeSchema.condition` is the first slot to compose + it. An `ast`-only envelope and a blank bare string surface as one + `invalid_union` issue at the slot carrying the sentence above; a blank + `source` inside an envelope surfaces as one `custom` issue at `source`. + - **`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed.** They remain + the persistence contract (`source` OR `ast`), whose docblock declares that + `ast` becomes required in build output at phase M9.2. When AST-only + evaluation lands, `EvaluatedExpressionSchema` is the one place to relax, and + every evaluated slot follows. + - **`structuralConditionRefusal` no longer admits an `ast`-only envelope** on + either structural condition slot (`config.condition` on a node, + `edge.condition`). #15662's refusal admitted it on purpose through a + `rec.ast !== undefined` clause, because the spec still admitted the shape at + `edge.condition` and refusing it from the consumer side would have decided + #15430's question there; with the edge schema closed, that admission kept the + refusal deliberately holed for a shape the engine cannot run on either slot. + `STRUCTURAL_CONDITION_SHAPE_REFUSAL` now reads "an expression envelope + carrying a string `source`" and says why. Consequence on `config.condition` + (a start node's trigger gate, a decision node's predicate — an open record + with no schema in front of it): an `ast`-only envelope there is refused at + `registerFlow`, reported as a located `error` by `objectstack validate`, and + refused by `evaluateCondition` with the same sentence, instead of answering a + silent `false`. An `ast` BESIDE a string `source` is still admitted + everywhere. The whitespace-only STRING ruling on `config.condition` (#15662: + consistent `false` on both sides) is untouched. + - **Three doors agree, through the spec.** `registerFlow` refuses the flow at + `FlowSchema.parse` (edge) or at its structural pass (`config.condition`); + `objectstack validate` refuses it at its `ObjectStackDefinitionSchema` parse + (edge) or reports the structural refusal (`config.condition`); + `evaluateCondition` refuses the shape a stored flow or a direct caller hands + it. None of them grew a rule of its own. + + **What an author does with a refused edge condition.** An edge condition that + carried only `ast` has no evaluable form under M9.1: author its `source`. A + whitespace-only condition — envelope or bare string — was never a predicate + (the engine answered `false`, so that edge never fired): remove the + `condition` key if the edge was meant to be unconditional, or write the + expression if it was meant to branch. Every edge condition with a + non-blank `source` is unchanged, and nothing is renamed, retired or rewritten — + the refusal itself carries the prescription. + + **A flow ALREADY STORED in `sys_metadata` stops running entirely — the whole + flow, not just the edge.** The paragraph above is the author's remedy, at + `objectstack validate` / `POST /flows`; a stored row has no author in front of + it. Stored flows are deliberately NOT canonicalized by + `applyConversionsToStoredItem` (`spec/src/conversions/stored.ts`, and the same + skip in `metadata/src/loaders/database-loader.ts`'s `rowToData`) — flow-node + conversions need the automation engine's live executor registry, so flows + canonicalize at `registerFlow` instead, which parses through + `canonicalizeStoredFlow` → `FlowSchema.parse`. Each of the three boot paths in + `service-automation/src/plugin.ts` wraps that call in `try`/`catch`, logs one + `warn` naming the flow, and continues. So an edge that used to answer a silent + `false` while the rest of the flow ran now takes the flow down with it: it is + never registered, its trigger is never armed, and the only announcement is that + one warn line — `[Automation] failed to register flow` at boot, + `[Automation] cold-boot flow bind: failed to register flow` at the kernel:ready + bind, `[Automation] flow re-sync: failed to register flow` on a re-sync. That + warn line is also the locator: its `issues[].path` names the offending edge — + `edges[N].condition` — beside the sentence above, so nothing has to be exported + to find it. Author the `source` — or remove the key, if the edge was meant to + be unconditional — and republish. A stack authored in config files has a second + door, `objectstack validate`, which locates the same edge at + `flows.N.edges.N.condition`. Registered as the ADR-0087 D3 semantic entry + `flow-edge-condition-evaluated-slot-source-required`, which carries the same + judgment for a consumer replaying the chain. + + Not touched here: `start.config.condition` has no Zod schema to narrow (the + start node's `config` is an open record); its producer-side gate is the + structural refusal above, which this change tightens but does not type. +- f8e5790: fix(spec)!: `grouping.fields[].field` refuses a padded field name instead of handing three renderers a lookup that always misses (#17360, ruling C on objectui#7347) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface. `GroupingFieldSchema.field` was a bare `z.string()`, so `' business_unit '` was valid authored metadata; it is now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying a padded grouping name now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-grouping-field-padded-refused`. + + ## What was wrong + + The padded name never failed anywhere. It failed to *group*. + + Measured on objectui (M1–M11, with live controls): the projection harvester `collectGroupingFieldRefs` **trims** the name when it builds `$select`, while **three** renderers bucket rows by the **raw** name — plugin-grid `usableGroupingFields`, plugin-list `ObjectGallery.groupedItems`, plugin-kanban `effectiveSwimlaneField`. So the server answers under `business_unit`, every per-row lookup asks for `' business_unit '`, reads `undefined`, and the view collapses into one `(empty)` group (grid, gallery) or one `Uncategorized` lane (kanban) holding every record. + + That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant `(empty)` group has no way to tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it. + + ## What it does now + + `grouping.fields[].field` carries a **non-padded** pattern — no leading and no trailing whitespace. The refusal lands at `grouping.fields[N].field` (the offending element's own key, not the view or the array) and names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, together with the trimmed name to write instead. + + ⛔ **Not a `.trim()`.** A trimming schema makes `' a '` and `'a'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. objectui's harvester trim stays as defence-in-depth; nothing is removed there. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `grouping: { fields: [{ field: ' business_unit ' }] }` | `grouping: { fields: [{ field: 'business_unit' }] }` | + | `grouping: { fields: [{ field: 'status\n' }] }` | `grouping: { fields: [{ field: 'status' }] }` | + + The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a view has been silently showing one `(empty)` group, re-authoring the name is also the fix for that. + + ## Scope — what is deliberately NOT narrowed + + - **The blank name is unchanged.** It is already refused loudly one layer down by `compileListViewGroupQuery`'s `grouping_field_blank` (`400`, path `['grouping','fields',N,'field']`). This narrowing exists for the **silent** case; the empty string still parses here exactly as before. + - **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and this key deliberately does not take it: a grouping level is authored as a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one. The ruling asked for a non-padded pattern and this is exactly that — nothing wider, nothing narrower. + - **The sibling `groupByField` axis** (kanban / gantt / timeline) is symmetric and is **not** touched by this change. + + ## Who is affected, measured + + Every `grouping.fields[].field` spelling in this repo parses unchanged: 50 literal occurrences under a `grouping:` key across 19 files, harvested with the TypeScript parser and cross-checked against a deliberately over-approximating second pass over 906 shape-exact `{ field, order?, collapsed? }` literals in `packages/**`. The single harvested spelling this refuses is `' '` in `view-grouping-query.test.ts` — a **negative** fixture handed straight to `compileListViewGroupQuery` with no parse on its path, pinning that same `grouping_field_blank` refusal. Nothing in the tree reddens. + + Outside the repo, only metadata that was already grouping wrongly is affected: a padded name has never produced a correct grouped view on any renderer. + + ## Consumer + + **objectui#7347 unblocks on the INSTALLABLE RELEASE of this package, not on merge.** Its side of the work — a pin bump plus a regression test that a padded name is refused before it reaches any renderer — needs a published `@objectstack/spec` to depend on, so it stays `pm:blocked` until this ships in a release a consumer can install. The gallery and kanban sites are covered by this one producer fix and get no cards of their own. +- d2c1d19: fix(objectql)!: `beforeUpdate` receives the record the engine intends to persist, and the caller's submission travels on `ctx.submitted` (#16344) + + + + **BREAKING** — what a `beforeUpdate` handler reads on `ctx.input.data` changes. A `readonly` field the caller supplied a value for is no longer there. The hidden set is the update strip's own subject set: author-declared `readonly: true` **and** the types whose value the runtime owns end to end (`autonumber`, implicitly read-only since #5503). `readonlyWhen` locks are deliberately not hidden. + + ## The defect + + On update, a value sent for a field declared `readonly: true` was correctly **not persisted** — and was still handed to the object's `beforeUpdate` hook. A hook deriving columns from the incoming record therefore derived them from a value the row would never contain, and **those derived writes persisted**, because they are the hook's own. + + Measured on a real app (17.2.0, sqlite, dev runtime) and reproduced in `packages/objectql/src/engine-readonly-hook-input.test.ts`. One `PATCH { actual_value: 380, target_value: 1, weight: 1 }` against a `readonly` `target_value`: + + ``` + read back: target_value 400 weight 10 ← the strip worked + score 1.2 calc_trace "实际 380 / 目标 1 … 权重 1%" + ``` + + The row's own audit trail cites values the row does not hold. No error, no warning, 200, and `droppedFields` correctly reporting the strip the whole time — every channel said the write was fine, because by every channel's own lights it was. The only way for an application to be safe was for every hook to re-read its read-only columns and ignore the incoming record, which defeats declaring them read-only at all. + + ## What changed + + **`ctx.input.data` on `beforeUpdate` is now the record the engine intends to persist.** Caller-supplied values for `readonly` fields are taken out of the hooks' view before the before phase is dispatched, and handed back at the engine's post-hook confluence — so the payload every engine-owned consumer below reads is byte-for-byte what it read before. `onFieldsDropped` reports the same fields with the same `readonly` reason, the read-only WARN says the same sentence, and `strictReadonlyWrites` refuses exactly the same writes. + + **The caller's submission travels on a new `HookContext` member, `ctx.submitted`** (`@objectstack/spec`, `HookContextSchema`) — the payload as sent, snapshotted at engine entry before any middleware or hook stamp, frozen, and documented as *diagnostics only, never the persist image*. It is bound on the update verb, both phases, and every per-row dispatch of one caller write. + + Two things deliberately did **not** move: + + - **The enforcement pass is still after the hooks.** It is the only point that can tell a hook's stamp from a caller's forgery (`hookWrittenKeys`), so a `beforeUpdate` that stamps a read-only column still lands — including when the caller echoed the same key back, which is the whole subject of #5591 / #14088. + - **`beforeInsert` is untouched.** The create side's strip position is settled post-hook by ruling C (#14147, "one semantics, one enforcement point"), and `readonlyWhen`-locked fields stay hook-writable per #9107. + + `@objectstack/plugin-auth`'s ADR-0092 identity write guard is migrated onto the new member in the same change, which is why nothing degrades: its 403 and its security warn still name the non-whitelisted field the caller sent. Without that migration the identical request answers `None of the submitted fields (—) are editable` — as strong a refusal, saying nothing about what was refused. Both readings are pinned side by side in `identity-write-guard.test.ts`. + + Ruled 2026-09-08 (maintainer, verbatim 「批 #87 同意」, director seat, decision batch #87). The refused primary was the same strip move **without** the new member: the ADR-0092 diagnostic degrades and every third-party `beforeUpdate` guard reading `ctx.input.data` degrades with it, silently. The refused alternative on the other side was documenting that hooks must read read-only columns from `ctx.previous` — which outsources the invariant to every application, the exact shape triage had already rejected. + + ## Who is affected + + A `beforeUpdate` handler that **reads a `readonly` field (declared, or runtime-owned) out of `ctx.input.data`**, on a non-`isSystem` write. Three shapes, and the fix is one line each: + + - **deriving a value from it** — this is the defect; the handler now derives from `ctx.previous`, or from `ctx.input.data` with the payload's absence meaning "unchanged", which is what it always meant for a field the caller never sent. + - **reporting on what the caller sent** (a guard naming the offending key) — read `ctx.submitted`. + - **a self-assignment** (`data.x = data.x`) on such a field — this used to promote the caller's forged value to hook-owned and commit it. It is now a **no-op**: the key the hook reads is gone, so the line re-creates it holding `undefined`, and the engine treats set-to-undefined of a hidden read-only key as the no-op it is — deleting the key, dropping it from the hook-write record, and letting the ordinary hand-back put the caller's value back for the strip to judge. **The stored value stands**, and the write reports exactly as it would with no hook at all (stripped, `onFieldsDropped`, the WARN, `strictReadonlyWrites` refusing). Persisting the `undefined` instead would erase the stored value on the memory driver and hand knex an undefined binding on a SQL one — neither is the record the engine intends to persist. That laundering route closing is intended, and it is re-pinned in both directions rather than removed. + + ⚠️ **The sharpest edge is a sandboxed `body` hook, and it is a refusal rather than a quiet change.** A body that reaches *through* such a key — `ctx.input.locked_meta.who = 'hook'` — now dereferences `undefined` and throws, and a `body`'s default `onError` is `abort`, so the caller's **whole write is rejected** where it used to succeed. What that body used to do was persist a value derived from the caller's forgery, so refusing is the correct direction; but the message the author sees is a raw `TypeError` from their own dereference and names nothing actionable. Measured end to end through a real QuickJS sandbox and pinned in `packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts`. + + A body hook cannot read `ctx.submitted`: it is deliberately not marshalled onto the sandbox face, for the reason `dispatch.scope` is not — that face is assembled key by key, and a key added there is a second published contract with its own compatibility story. A body deriving a column from a read-only field reads **`ctx.previous`**, the stored row, which is the correct source either way. + + ⚠️ **One ADR-0092 boundary changes a status code, and no in-repo object hits it today.** On an object whose UPDATE whitelist admits a field that is ALSO declared `readonly`, a whitelist-only payload now answers **403** where it used to answer **200 having written nothing**. The identity write guard composes its refused list from what the engine left it, and a whitelisted key is excluded from that list by design, so the refusal reads `None of the submitted fields (—) are editable` — naming nothing. The write was already being dropped by the read-only strip before this change; what moves is that the caller is now told, and told imprecisely. `sys_user`'s three writable fields are not read-only, so nothing in this repository is on that boundary; an application that puts a `readonly` field in an UPDATE whitelist should take it out, which is what the whitelist meant either way. + + An `isSystem` caller sees no change at all: the strip has never applied to one, and neither does the hide. +- 681871e: feat(spec): `HookContext` admits a row-invariant-in-effect rewrite by per-row `previous` on a predicate write, kept safe by the engine's key-divergence refusal (#16074) + + The `hook.zod.ts` contract said that on a predicate (`multi: true`) write the per-row `previous` is supplied *so a guard can REFUSE (throw), not so a rewrite can be aimed*. Three shipped `beforeUpdate` provenance stamps (`sys_email_template`, `sys_sharing_rule`, `sys_webhook`) read `ctx.previous` per row and write `customized: true` conditioned on it — inside the letter of what the engine allows, outside the stated purpose of the input they use. Maintainer ruling (recorded by the director seat, decision batch #59, 2026-09-06), option 1: **the contract admits the shape.** + + The amended D3 clause (`HookContextSchema.input` TSDoc, mirrored in `bulk-write-hook-conformance.ts`) now states: + + - Per-row `previous` is supplied so a guard can REFUSE, **and** so a `before*` hook can make a **row-invariant-in-effect rewrite** — one whose written KEY SET is the same on every matched row **and is assigned in place** (`ctx.input.data.customized = true`, not a wholesale replacement of `ctx.input.data`). + - What makes that shape safe is the engine's `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` refusal (#14099): the dispatch records, per row, the payload keys that row's hook chain assigned **in place**, and if any two rows disagree the whole batch is refused **before any write**. In place is the condition the refusal rests on: a hook that REPLACES `ctx.input.data` leaves the dispatch unable to attribute keys, so the comparison is skipped and the batch is not judged at all. + - What an operator sees when it fires: an ADR-0112 envelope with `status: 400`, `code: 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE'`, `keys` (the sorted keys some rows' hooks wrote and others did not, e.g. `['customized']`), `rows` (how many rows the predicate matched), `object`, and a message that says "Nothing was written" before naming the remedy. A bulk edit over rows that already disagree on the stamp's condition is refused whole rather than half-stamped; that is the engine working, not the hooks misbehaving, and the remedy is the caller's — write those rows by id, or from inside the handler through `ctx.api`. + - Three shapes the rule does **not** admit: a rewrite whose written key set differs across rows (that is the refusal itself); the same key written with a per-row VALUE — the engine judges key sets, never values, so that shape clears the check and applies the last dispatch's value to every row; and a row-conditioned REPLACEMENT of `ctx.input.data`, which silences the recording above so that shape is judged by nothing at all. All three stay out of contract. + + Purely additive at the contract: no schema key, type or accept set of `HookContextSchema` itself changes, and the engine's behaviour is unchanged — the three stamps become conforming by amendment, and the rule for the next hook author is written down where the contract lives. Option 2 (change the hooks to stop aiming by `previous`) was not adopted: #15302 measured that declining on a predicate write leaves unstamped exactly the rows the next boot overwrites, turning a visible 400 into silent loss of an admin edit. +- 4bbf766: Two surfaces the console renders that no translation bundle could address — a `kind: 'slotted'` page's components and a dashboard's global-filter bar — are now addressable (#16772). + + **BREAKING** (return shape) — `walkAddressedPageComponents` is a published export of `@objectstack/spec` and its return value is now the rebuilt roots pair `{ regions?, slots? }` where it used to be the regions array alone. A caller that only enumerates components through the visitor and ignores the return value is unaffected. A caller that reads the return value binds `const { regions } = walkAddressedPageComponents(doc, visit)` and reads `regions` exactly as it did before; `slots` is the other half of the same rebuild and is present exactly when the input page authors slots. The bump stays `minor` because the launch-window convention `scripts/check-changeset-no-major.mjs` enforces refuses a `major` while the fixed group is in lockstep — during that window the version number carries nothing about breaking-ness, so this banner and the disposition below are the carriers. + + **`walkAddressedPageComponents` widens in both dimensions.** The shared page walk behind `translatePage` and the CLI extractor (`os i18n extract` / `os i18n coverage`) rooted at `regions[].components[]` only and descended `properties.children` only. A slotted record page authors `regions: []` and puts everything under `slots.`, so the walk visited nothing on it and `pages.` carried exactly two addressable keys however many components the page authored; a `page:tabs` / `page:accordion` keeps its panels' components under `properties.items[].children`, one level deeper than the descended slot, so a related list inside a tab was unreachable on any page kind. The walk now roots at `regions[].components[]` **and** `slots.` (one component or an array per slot, regions first, then slots in authored order — both root level for the collision arbitration and for the page-name `page:header` route, so a slotted page's `slots.header` is translated as the page's header), and descends `properties.children` **and** `properties.items[].children` (matched by shape, so a custom container speaking the same vocabulary is walked too; `body` / `footer` remain undescended — a renderer back-compat fallback, not an authorable spelling). The depth cap, the cycle guard and the ruled id arbitration are unchanged. + + - Signature: the parameter is `AddressedPageRoots` (= `Pick`) instead of `Pick`, and the walk returns the rebuilt roots pair `{ regions?, slots? }` (each key present exactly when present on the input) instead of the regions array alone. `PageLike` gains `slots`. An enumeration-only consumer that ignores the return value needs no change; a consumer reading the returned regions destructures `{ regions }`. + - `translatePage` carries the rebuilt `slots` back onto the document. + + **`dashboards..globalFilters.` is a new bundle group.** A dashboard's filter bar draws directly above the widget titles the bundle has always translated, and neither a filter's label nor its static option labels had a key. The group is keyed by the filter's `name` (`GlobalFilterSchema.name`, declared as defaulting to `field` — a filter that authors no `name` is keyed by its `field`) and carries `label` and an `options.` map keyed by the option `value` spelled as a string. `translateDashboard` overlays it on the served document, which is what objectui's filter bar already reads; the exported `globalFilterKey()` is the one key derivation both the resolver and the extractor use. `optionsFrom` options are fetched rows and are deliberately not addressable. + + **`@objectstack/cli`:** `os i18n extract` offers `dashboards..globalFilters..label` / `.options.` for every static filter, and `pages..title` / `.subtitle` for a `page:header` at any root (a slotted page's `slots.header` included) — the component keys under `slots` and tab panels follow from the shared walk with no extractor change. + + **`@objectstack/platform-objects`:** the shipped Setup bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`) carry the new `dashboards..globalFilters.created_at.label` entry for the system-overview dashboard's date-range filter, which authors no `name` and is therefore keyed by its `field`. + + **Why no ADR-0087 ledger entry.** Nothing an author writes moves. The authorable side is purely additive — `dashboards..globalFilters.` is a new optional group and every bundle that was valid before is valid unchanged — no spec key is retired, no stored `sys_metadata` shape changes, and no conversion or migration id is touched, so `objectstack migrate meta` has nothing to act on. The one incompatible surface is a published function's TypeScript return type, which reaches every affected consumer through the compiler. + + +- 9cdffbe: One physical representation for the NUMERIC column family, read by every producer of DDL + + `packages/spec` now states, per field type, what column a numeric field gets, and all three + producers read it: `SqlDriver.createColumn`, `os generate migration --format sql` and + `os generate migration --format typescript`. Measured on live PostgreSQL 16.13, one object + through all three producers, before and after: + + ``` + BEFORE AFTER + driver sql gen ts gen all three + number real numeric(18,2) numeric(8,2) numeric(65,30) + currency real numeric(18,2) numeric(8,2) numeric(65,30) + percent real numeric(5,2) numeric(8,2) numeric(65,30) + slider real numeric(18,2) numeric(8,2) numeric(65,30) + summary real numeric(18,2) numeric(8,2) numeric(65,30) + progress real numeric(5,2) numeric(8,2) numeric(65,30) + rating real integer integer integer + ``` + + 7 of 7 columns diverged before, 0 of 7 after. Every arm of the old split lost data in its own + direction: `real` is IEEE-754 binary32, so a `currency` of `1234567.89` read back `1234567.9`; + `numeric(5,2)` and `numeric(18,2)` silently ROUND a legitimate `33.333` to `33.33` (round + half-up — executed, not inferred); `numeric(8,2)` refused `1234567.89` outright. `65,30` is + MySQL's documented `DECIMAL` maximum and therefore the portable one, and it is the only + candidate measured to lose nothing on a nine-value corpus. + + Both migration formats also take the physical `NOT NULL` from `storage.notNull` and never from + `required`, which is where `SqlDriver.createColumn` has taken it since ADR-0113: `required` is + the write-time contract the record validator enforces, and binding the DDL to it made every + post-deploy tightening a destructive migration. + + **BREAKING** — new columns only; no existing column is retyped, no migration is planned, and no + backfill runs. Four consequences to know before creating new tables: + + - `rating` is an INTEGER column, and the two server dialects dispose of a fractional star count + DIFFERENTLY — do not read one answer for both. PostgreSQL REFUSES `4.5` outright, where a + `real` column accepted it. MySQL does NOT refuse: it ROUNDS, and `4.5` becomes `5` with no + error, which is a silent alteration and the reason to declare a `slider` (in the exact-decimal + set) for anything that wants fractional values. SQLite refuses nothing either: it stores `4.5` + as a REAL in an INTEGER-affinity column, unchanged from today. + - An exact-decimal column is bounded where a float is not, in BOTH directions. It keeps 30 + fractional digits: a magnitude whose significant digits run past the 30th decimal place loses + the tail silently — `1.2345678901234567e-15` stores as `0.000000000000001234567890123457`, so + the loss begins around |x| < 1e-13 and is total below 1e-30 — and magnitudes at or above 1e35 + are REFUSED, where `real` kept about seven significant digits out to ~1e38. A refusal is loud; + the rounding it replaces was not. + - Reads are bounded by the wire contract, not by the column. `find()` hands back a JS number + (`z.number().finite()`), so a value that was never a JS double does not survive the round trip + exactly — `1234567890123456.123` reads back `1234567890123456`, and 2^53+1 reads back 2^53. + The fidelity this buys is an exact COLUMN read through a double: values written by this + platform round-trip exactly, and SQL-side writers, `summary` roll-ups computed in SQL and any + magnitude at or above 2^53 are bounded by the read seam. Widening that is a wire-contract + change and is not in this release. + - A generated migration no longer emits `NOT NULL` for a field marked only `required: true`. + Declare `storage: { notNull: true }` for a physical constraint — which is what the platform's + own table has always done since ADR-0113, and what `os migrate meta` deliberately does NOT + supply on your behalf (the conversion that stamped it was withdrawn by maintainer ruling on + 2026-09-08). A source author who wants the column they had must write that block themselves; + `required: true` keeps its own meaning, the write-time contract the record validator enforces. + + SQLite emits byte-identical DDL for the six exact-decimal members: knex compiles both + `table.decimal(name, p, s)` and `table.float(name)` to the same `float` column there. + + +- 331a1a2: fix(security): an OAuth-connected MCP agent runs at its delegator's record depth — "you connect as yourself" becomes true (#16549) + + Maintainer ruling, decision batch #81 item 1 (2026-09-08), option 1: **the OAuth agent runs with the user's own permissions; the ceiling only subtracts; the diagnostic lands regardless.** + + **The defect, measured.** The Setup → Connect an Agent page promises, verbatim, *"you connect as yourself, and every call runs under your own permissions and row-level security."* It did not. The same sales manager, same questions, same server: + + | identity path | `crm_account` | `crm_opportunity` | `crm_task` | + |:--|--:|--:|--:| + | API key, `principalKind: human` | 9 | 23 | 45 | + | OAuth, `principalKind: agent`, `onBehalfOf` = same user | **5** | **0** | **0** | + + The agent read `own` scope where the human read `viewAllRecords`, so any profile whose visibility comes from `viewAllRecords` — every manager-type profile — collapsed to *own + explicit shares*. And it was **silent**: the MCP tools answered `total: 0` with no note, so the agent reported "there are no opportunities this quarter" as a fact about the data. + + **The mechanism, in one line.** `mcp_agent_data_read` / `mcp_agent_data_write` are pure CAPABILITY ceilings — a `'*'` grant with no `readScope` and no `viewAllRecords`, whose own doc says *"NO row-level security … all row/owner/tenant narrowing comes from the delegating user"*. `PermissionEvaluator.getEffectiveScope` nevertheless answered `'own'` for them, because its owner-only default turns a granting-but-silent set into an owner-scoped one. That default is correct for a principal standing on its own and wrong as an input to an intersection: it made the ADR-0090 D10 fold subtract with an opinion nobody declared. + + **(1) Parity.** A new `PermissionEvaluator.getDeclaredScope` answers the depth a set actually *declares*, or `undefined` when every granting set is silent; `intersectDelegatedScope` reads that silence as **no opinion**, so the delegated principal's own leg contributes no owner narrowing and the delegator's depth stands — `agent ∩ user = user` for visibility. A ceiling that *does* declare a depth keeps its full subtractive force. The explain engine's `depth` layer folds through the identical function, so a report cannot describe an intersection the query did not have. + + ⛔ **Only visibility depth moved.** Each ceiling's remaining subtractions are now written down explicitly beside the sets themselves (`objects/default-permission-sets.ts`): `data:read` still cannot write, create, delete, export or `allowTransfer`; `data:write` still cannot `allowTransfer` or export, and `sys_*` / better-auth-managed identity tables stay read-only; neither reaches a `private`-posture object nor carries any `systemPermissions`; a dangling delegator still fails CLOSED; and share-MANAGEMENT authority is still not delegated (`hasWriteBypass` → `false`, `resolveWriteScope` → `'own'` for any on-behalf-of context). Putting `viewAllRecords` / `modifyAllRecords` on the ceiling — the ruling's other permitted route — would have granted `allowTransfer` (`MODIFY_ALL_WRITE_KEYS` covers it) and reached `private` objects through the superuser wildcard, both explicitly fenced off, which is why the fix lands on the intersection instead. + + **(2) The diagnostic, independent of (1).** `ISecurityService.describeDelegationNarrowing` (optional) reports whether the agent ceiling narrowed a delegated read, resolved from the same two evaluator calls the CRUD middleware stashes as `__readScope`. `McpDataBridge.diagnoseDelegation` (optional) carries it to the transport, and MCP `query_records` serves a narrowed result with `delegationNarrowed: true` plus a `warning` sentence naming the D10 intersection — the `partial` / `warning` shape `list_objects` already uses. The rows are still served; what is added is the fact the payload could not previously carry: *this count describes the ceiling, not the object.* An un-narrowed read, a non-delegated read, a bridge with no probe and a throwing probe all render exactly what they rendered before. + + **(3)** The Setup page's promise is untouched — it is now true rather than rewritten. + + Purely additive on every published surface: two new optional members, one new exported type (`DelegationNarrowing`), and one new evaluator method. No existing member changed shape, and the only behavioural change is on the delegated path with a ceiling that declares no depth. + + `DelegationNarrowing` is a **discriminated union** on `narrowed`, not one shape with three optional fields, because the two shapes are not symmetric once released: + + | direction, after release | consumer cost | + |:--|:--| + | ship optional fields, later tighten them to required | a compile break | + | ship discriminated, later loosen it (a new union member, or an optional field on the `true` arm) | none | + + The loose shape buys nothing and forecloses the tightening. It also removes the very failure mode the method exists to prevent: `statement` is the sentence an AI consumer renders, so left optional, a consumer that forgets the `narrowed` check silently renders `undefined` — the same silence the table above measures. The five-member scope ladder it reports names the alias that already exists for it, `ObjectAccessScope` (ADR-0057 D1, `@objectstack/spec/security`), rather than minting a second declaration of one ladder; `resolveWriteScope` now names it too, so the union is spelled once instead of three times and no export is added beyond `DelegationNarrowing` itself. +- 9788f1e: feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B) + + + + **BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort` + and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the + repo's launch-window convention for breaking changes; the migration prescription + is registered under protocol major 18 as `object-block-sort-item-array`. + + One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, + 2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is + objectui PR #8758, which drops the legacy string arm from + `convertSortToQueryParams`). Item 4 of that ruling is this release's subject: + 「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the + `sort` value to the array shape (today it accepts anything), so the spec, the + registrations and the helper agree; that is a pull-back to the declared contract, + ordinary tier」. + + Until this release both doors declared `z.unknown()` — no orthography at all. + Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the + change: an array, the legacy string clause and a bare NUMBER all returned + `success: true`, while `bogusProp` was refused by name on the same call. So key + checking was live and only the VALUE was unheld, and an author following + objectui's own registrations (`plugin-grid/src/index.tsx:222` has published + `type: 'array'` all along) and an author following the legacy string each got a + silent success receipt for a different shape — while objectui's html tier + answered `type-mismatch` on the second one. Both doors now declare + `z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`, + `ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already + carry: one shared schema, not a third copy. + + Sequenced measurement-first, as this family has to be. At the objectui pin this + repo builds against (`53ded82b`) the string is still lowered — + `ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto + `$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort` + to `convertSortToQueryParams`, whose string arm is still present at + `sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned + consumer, which the ruling permits explicitly — either order, since the + registrations already declare the array — and the next pin bump carries the + retirement in. + + **Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes + `sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name + `sort: 'created_at'` meant ascending and becomes + `sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in + `SortItemSchema`, so it is written out rather than omitted; a comma-separated + clause becomes one array entry per key, in the same order. The string is refused + at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or + absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten + and this disposition adds no D2 conversion — a stored page carrying a string + `sort` keeps loading and still renders at the pinned `.objectui-sha`; what + changes is that RE-SAVING it is refused at the `sort` door. + + **Not moved by this release.** `record:related_list.sort` keeps its declared + string arm: that string is the `'field'` / `'-field'` dialect read by + `RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and + retiring it was not ruled — objectui#8221's own implementing round narrowed it, + established the dialect and reverted the narrowing byte-identically. + `object-grid.defaultSort` is a different key, already retired by #11805. Zero + authored `sort` values on either block exist in this repo (the two showcase pages + that author `object-grid` declare none), so nothing in-tree was converted. + + Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither + block's parsed state moves for this key, and both already take the + `…PropsParsed` route for `filter` (ADR-0122). +- 5d527f7: fix(spec): `PageSchema`'s rejection guidance stops prescribing `assignedProfiles` as a page gate (#16929) + + The two wrong-layer prescriptions `PageSchema` hands an author at parse time both ended by pointing at `assignedProfiles`: the `visibleWhen` pointer said "or gate the page with `assignedProfiles`", and the `permissions` pointer said "reach it through `assignedProfiles`". Neither is true. `assignedProfiles` gates nothing. + + Measured 2026-09-10 on `origin/main` `e1eee43beb` and objectui `3fbdd4a2d`: `assignedProfiles` has **zero readers** in this repo — every one of its 25 matching files is a declaration, a generated artifact, prose, a `CHANGELOG`, the liveness ledger, or this schema's own round-trip test — and **zero readers** in objectui, whose three hits are a docs table row and two type/zod declarations. Lit controls in the same sweeps (`visibleWhen` 308 files, `PageSchema` 94 files in objectui; `visibleWhen` 168 files here) prove the instrument fired; a fabricated dark control read 0 in both. The key is also named for the concept **ADR-0090 D2** removed, which `security/permission.zod.ts` states to authors three times over. + + Prescribing it was Prime Directive #10's exact prohibition — advertising a capability the runtime does not deliver — delivered to the author in the error that is supposed to be teaching them the correct spelling. Both prescriptions now say only what the platform actually does: put `visibleWhen` on the component inside a region, and gate the DATA a page shows with the object's permission sets. + + **Nothing about what `PageSchema` accepts changes.** `assignedProfiles` remains an authorable key with its declaration untouched, and the `profiles:` / `assignedTo:` alias entries are untouched. Both channels edited here fire only from the `unrecognized_keys` path, so every key involved is rejected before this change and rejected after it, with identical `issue.code` and identical `path` — only the human-readable text moves. The key's own disposition (keep, rename, or remove) needs a ruling and stays open on #16929. +- 9165d5c: Declare the ASSEMBLED manifest stage on the installed-package read API. + + `GET /api/v1/packages` and `GET /api/v1/packages/:packageId` serve whatever a + package was installed with, and two stages reach that table through declared + doors: `POST /api/v1/packages` installs an authoring manifest (`manifest.objects` + = glob patterns), while a `defineStack()` host installs the assembled body + (`manifest.objects` = object definitions). Both response schemas typed every row + at the authoring stage alone, so the shipped `defineStack()` path served a + payload its own declared contract refused. + + Following the #14242 ruling — declare the assembled stage rather than widen the + authoring one — `@objectstack/spec/api` gains two exports: + `AssembledInstalledPackageSchema` (the assembled-stage counterpart of + `InstalledPackageSchema`) and `InstalledPackageAtEitherStageSchema`, a union + over the two whole closed stage declarations. `ListInstalledPackagesResponseSchema` + and `GetInstalledPackageResponseSchema` are bound to the union. + + This is additive at runtime, and the runtime parse is where the gain is: every + payload that parsed before still parses, payloads that were refused for their + manifest stage now parse, and a row belonging to neither stage — an `objects` + array mixing globs with definitions — is still refused. `ManifestSchema` is + unchanged. + + The STATIC gain is one-sided, and smaller than a union normally implies. + `AssembledPackageBodySchema` is annotated `z.ZodType, …>` + in `stack.zod.ts` — deliberately, for the declaration-size reasons recorded + there, and untouched by this change — so the assembled branch carries no field + typing. Measured against the built `.d.ts`: a plain `.manifest.version` read off + one of these two response types now yields `unknown` where it used to yield + `string`; narrowing toward the AUTHORING branch restores the whole of + `ManifestSchema` (`version: string`, `objects: string[]`), while narrowing away + from it yields `Record` — every manifest field `unknown`. In the + assignment direction the assembled branch admits any object at `manifest`, so a + garbage manifest and the mixed-stage row named above both typecheck clean even + though the runtime union refuses both. So: narrow at the point of use for the + authoring stage, and treat an assembled manifest as a record the runtime — not + the compiler — has checked. + + `@objectstack/spec/api` also gains a `browser` export condition. Declaring the + assembled stage makes this entry's module graph reach the datasource + declaration and with it the driver-config validators, whose postgres URL + refinement links `pg-connection-string` — a package whose `parse` statically + resolves `require('fs')`, so a browser bundler that reaches it fails on + `Can't resolve 'fs'`. The entry now resolves, for browser consumers only, to a + build with the pg-grammar arm swapped for its dependency-free twin: exactly the + boundary the four entries that already carry the condition use. Node resolution + and the Node bundles are unchanged, byte for byte. For browser consumers the + postgres `url` refinement degrades to the shape-only checks it already performs + before `parse` — the unix-socket short-circuit and the refusal of the + filesystem-reading `?sslcert=` / `?sslkey=` / `?sslrootcert=` query parameters + are kept; the "is this a URL `pg` can open" arm answers "no findings". Datasource + publish is a server-side act, so that arm never legitimately ran in a browser. +- 07150b3: `PluginSchema.version` now accepts the whole of the SemVer 2.0.0 grammar, and `version` becomes the ninth declared key `kernel.use()` enforces. + + Two declarations in this repository disagreed about what a plugin `version` is, and the disagreement became load-bearing the moment the boot path started running the schema: + + | Declaration | Grammar | Accepted `1.0.0-alpha.1` / `1.0.0+20230101` | + |---|---|---| + | `PluginSchema.version` (`@objectstack/spec`, `kernel/plugin.zod.ts`), described `"Semantic Version"` | `/^\d+\.\d+\.\d+$/` | **no** | + | `PluginLoader.isValidSemanticVersion` (`@objectstack/core`), the check the boot path has always run | `/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/` | **yes** | + + SemVer 2.0.0 defines prerelease and build metadata as **parts of** a semantic version, so the key's own `describe()` — `"Semantic Version"`, no qualifier — claimed the wide grammar while its regex implemented a subset of it. The spec key was the one that was wrong, and it is the one that moved. + + **The spec adopts the loader's grammar character for character**, deliberately, rather than a third spelling: that is the check the boot path has always run, so the two declarations now converge exactly and nothing that loaded before is refused now. + + **`@objectstack/spec` — a WIDENING of a published contract.** `Plugin.json`'s `pattern` in the shipped `json-schema/` tree changes from `^\d+\.\d+\.\d+$` to `^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$`. This is a strict superset — same three-segment core, two **optional** suffix groups — so every string that validated before still validates. A tool that mirrors this schema to validate plugin manifests should widen with it; one that does not will merely keep refusing prerelease versions the platform accepts. + + **`@objectstack/core` — `version` joins the enforced set, which NARROWS `LiteKernel`.** **BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** `assertPluginContract` filtered `version` issues out while the two spellings disagreed; that stopgap is gone. The full enforced set is now **NINE** keys, each refused with the offending key named in the message: + + - **`id`** — a non-string, or the empty string. + - **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. + - **`staticPath`** — a non-string. + - **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. + - **`default`** — a non-boolean. + - **`version`** — a non-string, or a string outside the SemVer grammar above. **New in this release.** + - **`description`** — a non-string. + - **`author`** — a non-string. + - **`homepage`** — a non-string, or a string that is not a URL. + + **`null` is refused on every one of the nine**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is still refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + + ⚠️ **This supersedes the eight-key enumeration published in `@objectstack/core@17.4.0`.** Both of that release's entries — the `kernel.use()` and the `LiteKernel.use()` enforcement notes — say the enforced set is eight keys and that `version` is excluded, and both point at reconciling the two `version` spellings as separate spec work. This is that work. Those entries stay as written, because they describe what 17.4.0 did; **nine is the current set**, and `version` is no longer excluded from anything. + + **What actually changes behaviour, stated narrowly.** On **`ObjectKernel`** nothing moves: `PluginLoader.validatePluginStructure` already judged `version` with this exact grammar and still runs first, so a malformed `version` is still refused as `Invalid semantic version`, never as `PLUGIN_CONTRACT_VIOLATION`. On **`LiteKernel`** a plugin object with a malformed `version` — `version: 'v1.0.0'`, say — was **registered** before and is **refused** now, with `PLUGIN_CONTRACT_VIOLATION` at `'version'`. `LiteKernel` has never run the loader's structural checks, so `version` was the one declared key it did not judge at all: such a plugin was green in vitest and refused by `ObjectKernel` at production boot. That is exactly the split the `LiteKernel` convergence closed for the other eight keys, closed now for the ninth. + + **What is unchanged.** `1.0.0-alpha.1`, `1.0.0+20230101` and `0.0.0-fixture` load on **both** kernels, as they did before — measured, not assumed, and pinned per kernel. A version-less plugin still loads; `version` is `.optional()`. Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the object that was passed in). A class-based plugin keeps its identity, prototype and prototype methods. + + ⚠️ **The accepted grammar is wider than SemVer 2.0.0 itself**, and this release neither introduced nor widened that fringe: leading zeroes in the numeric core (`01.1.1`) were accepted by **both** spellings before this change and are accepted by both after it, and the loader's prerelease/build classes admit degenerate identifiers SemVer forbids (`1.0.0-alpha..1`, `1.0.0-0123`, `1.0.0+.`). Tightening to the official SemVer regex would have **narrowed** this key rather than widening it, so it is deliberately not done here. + + **Migration.** Nothing to rename, and nothing to do if your plugin's `version` is a real semantic version. If you register plugins on `LiteKernel` with a `version` string that is not one — a leading `v`, a two-segment `1.0` — spell it `MAJOR.MINOR.PATCH` with optional `-prerelease` and `+build`, or drop the key. The refusal names the plugin and the key. + + +- d2badf7: feat(spec): a repeater's property-panel table has column NAMES, and an untitled item schema is now loud (#17232) + + ## What was wrong + + Studio renders a `type: 'repeater'` form field as a table whose column headers + read `items.properties[k].title ?? k` off the JSON Schema served by + `GET /meta/types` — derived by `packages/metadata-protocol`'s `toJsonSchemaSafe`, + i.e. `z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any' })`. + The bundle overlay `resolveMetadataFormSchemaTitles` (#16458 / PR #17227) only + replaces a title that is already there, so an item schema carrying no + `.meta({ title })` falls through to the raw machine key — in **every** locale, + English included. The maker read `actionUrl`, `defaultCollapsed`, `dateGranularity` + inside an otherwise fully translated panel. This is a missing authoring label in + the contract, not a translation gap. + + PR #17227 titled exactly one repeater, `dashboard.header.actions`, and was scoped + by dispatch to that one. **The class stayed silent**: the next repeater to land + would reproduce the defect with every gate green. + + ## Measured on `origin/main` at `e758131b39` + + 22 repeater fields are declared across 11 `*.form.ts` files. Derived through the + platform's own predicate rather than a source regex: + + - **1** was fully titled — `dashboard.header.actions`, PR #17227's instance. + - **1** has no object row shape at all — `action.locations` is an array of enum + STRINGS, so it renders no column headers and leaks no key. It is **not** a + carrier, which is why the class is **20** untitled tables today and not the 21 + the card premised. + - **20** were untitled. + + ## What changed + + **Thirteen carriers are now titled** — every row property of `action.params`, + `app.areas`, `dataset.dimensions`, `dataset.measures`, `flow.nodes`, + `flow.edges`, `flow.variables`, `page.variables`, `page.regions`, + `page.interfaceConfig.sort`, `report.order`, `report.blocks` and + `skill.triggerConditions` carries a `.meta({ title })`. `page.interfaceConfig.sort` + is titled through the shared `SortItemSchema` it composes. + + **The silence is closed.** `packages/spec/src/kernel/repeater-item-titles.test.ts` + enumerates every repeater declared across every `*.form.ts` in the package, + derives each row schema through `z.toJSONSchema`, and requires a title on every + authorable row property. Carriers still owed one sit in an EXACT, shrink-only + ledger: a repeater absent from the ledger must be fully titled, and a ledger + entry whose debt has been paid must be deleted. A new repeater is therefore red + on the day it lands, and the ledger can only shrink. + + Two exclusions the pin makes deliberately, each with its own control: + + - a `retiredKey()` tombstone is a parse-time refusal, not an authorable column + (`flow.nodes[].outputSchema`); + - a scalar-item repeater has no row properties to name (`action.locations`), + and is pinned by name so an object-shaped one cannot land there silently. + + ## What is still owed, and why + + Seven carriers remain on the ledger because their item schemas live in files held + by other in-flight PRs at the time of writing — `dashboard.widgets` and + `dashboard.globalFilters` (`ui/dashboard.zod.ts`), `view.columns` / `view.sort` / + `view.tabs` (`ui/view.zod.ts`), and `field.options` + `object.fields.options` + (the one `SelectOptionSchema` in `data/field.zod.ts`). The pin OBSERVES them + without editing them, so the ledger states the whole class rather than the slice + one PR could reach. + + Localisation is additive and unchanged by this round. `.meta({ title })` is the + English authoring layer by contract — `translation.zod.ts` states it in those + words — and a bundle's `metadataForms..fields...label` + overlays it per locale. No form file here enumerates repeater children, so + `os i18n extract` emits no new catalog keys and no catalog moves. Until those + leaves are authored, a non-English panel shows the English title rather than the + machine key — strictly better than today, and the localisation layer is still owed. +- d64bcb6: **BREAKING** — retire the `adr-0030-notification-event` data migration. + + `migrateSysNotificationToEvent` had no way to be run: zero production callers + anywhere in the repo, and no `os migrate` sub-command, while the two sibling + members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel + export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's + pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed + together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform + on this line. + + ## What is gone, and what an upgrader does about it + + ⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to + adopt — every item below is a deletion, and the fix is to stop using it. + + - `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. + - `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. + - `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + + `NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A + deployment attested at birth, or one that made the operator call while the runner + shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, + and the constant is that row's name. Nothing writes or reads a row under it any + more — `attestFreshDatastore` no longer includes it — and it is not a + registration: it gates nothing and never did. + + ## Reversal path + + Two answers were considered and both refused: an `os migrate notification-event` + sub-command is a permanent operator surface for a migration with no measured + demand, and a boot-time invoker is an unattended data rewrite nobody asked for. + ⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 + `sys_notification` rows. If a **named** deployment turns out to hold rows it + needs, the migration returns as an operator-runnable sub-command shaped exactly + like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, + documented consequence — under its own card. + + +- d4f5232: **BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding. + + A list view could declare `type: 'page'` and name a published page in `pageName`, + and the view was to render nothing of its own and delegate to the page renderer. + Only the spec half of that was ever built. **No renderer ever routed the member**: + objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page + view has always drawn an empty table where the page was supposed to be, and the + three parse refusals that policed the binding policed a mount that never mounted + anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09. + + ## FROM → TO + + | you wrote (17.4 and earlier) | write instead | + | --- | --- | + | `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` | + | `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone | + | a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged | + + **The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put + the page behind an app navigation item, which is a different key on a different + surface (`PageNavItem.pageName`) and is the page mount that has always rendered. + + `os migrate meta --from 17` lists the mechanical edits for existing sources; apply + them by hand. + + ## The retirement kit + + - **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and + `ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse + raises the prescription rather than a bare unrecognized-key report. + - **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on + (the def survives, one value lighter, and the four generated-surface ratchets are + blind to that by construction). The `type` enum's own `error` map carries it, + keyed on `issue.input` so only the value that used to be legal gets the + "was removed" message; every other invalid `type` keeps zod's default text. + - **`checkListViewPageMount`** — the exported object-level refinement existed only + to police this mount, so it is removed with it, along with its three refusal + messages. A downstream mirror that re-attached it (the reason it was exported) + should drop the `.superRefine` line; the compiler delivers this one. It held no + `ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes. + - **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the + `os validate` and publish-gate rule that resolved a mount against `stack.pages`. + Removed: there is no reference left to resolve. Its nav twin + (`validateNavTargetRefs`, on the app navigation item) is **untouched**. + - **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of + `CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page + universe joined the per-write snapshot for that one rule, and leaves with it. A + `PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a + collection nothing consults. Hosts calling `runRuntimeAuthoringRules` / + `evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key. + - **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's + `pageName` against `stack.pages` is gone. The surviving three page references in + that function (an app nav item's `pageName`, a modal action's `target` at two + rungs) keep their own policy. + - **The metadata form** — `view.form.ts`'s `page` section, whose one input was + `pageName`, is removed. A form input for an unwritable key is the false-compliant + UI half of a retirement. + + ## What an operator with a STORED page view sees + + A `sys_metadata` `view` row written before this release can carry `type: 'page'` and + a `pageName`. Nothing breaks at read: the ADR-0087 conversion + `view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys, + so the row is served canonical. `type` is **stripped, not rewritten** — it defaults + to `grid` in the schema, so the row lands on exactly what it already rendered + without the platform guessing a view type. + + The strip is announced once per row per process, on whichever seam served it. + Grep for `carries a pre-protocol shape` — there are **three** emitters, one per + rehydration seam, and they differ: + + - `[DatabaseLoader] stored view/ carries a pre-protocol shape; ` + - `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` + - `[Protocol] stored view/ carries a pre-protocol shape; The row + itself is unchanged — re-save it (Studio edit -> save, or run + "os migrate meta --stored --apply") to persist the canonical shape.` + + `os migrate meta --from 17` lists the same edits for authored sources; + `os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and + the next save through `PUT /api/v1/meta/view` heals one row the way it heals any + pre-protocol shape. + + ⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does + **not** reach `objects[].listViews.*`, which no conversion in the registry reaches. + An object body still carrying a page mount is refused at its own door with the + prescription rather than converted. Measured population for both at the ruling: + **zero** authored `type: 'page'` list views in this repository or any consuming app + the seats can read — the in-tree `type: 'page'` hits are all app nav items. + + +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. +- 3b1dab9: Declare `continueRestoredRun` on the `IApprovalService` contract, so the approvals half of the operator repair pair is reachable through the published interface rather than only off the implementation class. + + `IAutomationService.restoreConsumedSuspension` re-arms the pause a failed resume consumed and, by its own contract, does not replay the resume signal — the continuation must be re-issued. For an approval suspension nothing could re-issue it: every front door guards on a live request — `pending` for decide and send-back, `returned` for resubmit, and `pending` or the revise window for recall — and the stranding call leaves the row where none of them can issue the continuation it owes. The issuer landed as a class member on `plugin-approvals`; this declares it, so a caller programs against the contract instead of importing the implementation. + + Additive and OPTIONAL, the way `cancelRun` / `restoreConsumedSuspension` are declared on `IAutomationService`: an existing implementation still conforms, and a service that does not declare the member has no operator door for it — a caller must probe for presence and refuse fail-closed rather than answer success for a verb it could not dispatch, because promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared or implied. +- 1555ed4: `CLOUD_PROVIDED_OBJECT_NAMES` (`@objectstack/spec/system`) gains a member: + `sys_package_version`. `isPlatformProvidedObjectName('sys_package_version')` now + returns `true`, so a reference to that name resolves instead of being flagged as + a platform-prefixed name nothing registers (#16745). + + This widens an accept set. The name was previously refused, the list is a closed + set, and nothing in the published header enumerated this member — so the ladder + now accepts a value it used to warn on, and the widening reaches every surface + that consults the predicate: a dataset `object`, an action parameter + `reference`, a dashboard `optionsFrom.object` and a navigation `requiresObject` + naming `sys_package_version` all stop being diagnosed. + + Why this name and not another: the list already carried `sys_package` and + `sys_package_installation` — the head and tail of the three-table package family + that `cloud/package.zod.ts` declares — but not the release-snapshot table + between them, whose row schema this repository ships as + `cloud/package-version.zod.ts`. Platform metadata that ships with the product + references it: `sys_metadata.package_version_id` in `@objectstack/metadata-core` + is a `Field.lookup('sys_package_version', …)`. + + One entry is added; no other member moves and nothing is removed or narrowed. + The cloud-side half of the contract — that `@objectstack/service-tenant` + registers the table — is owned by the cloud repository per the list's header and + is not asserted from here. +- 776d64c: feat(spec)!: the `@objectstack/spec/cloud` subpath is removed — the cloud control plane's contracts leave the open-source spec, and the package & marketplace format moves to `@objectstack/spec/marketplace` (#16325) + + + + **BREAKING** — a published subpath export of `@objectstack/spec` is deleted, with no + alias and no deprecation window (maintainer, 2026-08-27, verbatim: 「项目在创业阶段, + 用户也很少,短期不考虑渐进。」). Shipped as `minor` under the repo's launch-window + convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness + is carried by this banner plus the ADR-0087 disposition; the hand-migration prescription + is registered under protocol major 18 as `cloud-subpath-retired`. + + ## What moved, and why + + Maintainer direction (2026-09-06, verbatim): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」, + ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). + `packages/spec/src/cloud/` held two families with different owners: + + - **The cloud control plane's own contracts** — `environment.zod`, `environment-package.zod`, + `tenant.zod`, `developer-portal.zod`, `marketplace-admin.zod`, `app-store.zod` (62 JSON-Schema + defs, 2087 lines). Their producer and every consumer live in the closed cloud repo; the + open-source tree read exactly one type from them. They are gone from `@objectstack/spec`: + `environment` and `tenant` are re-declared in the cloud repo (objectstack-ai/cloud#2037), and + the other four are deleted outright — zero consumers in any repo (#16526, ruled A). All of it + is recoverable from git history at `d5d8d50db`. + - **The package & marketplace format** — `package.zod`, `package-version.zod`, `marketplace.zod`, + `package-l10n`, `template-manifest.zod` (30 defs, 1400 lines). A package author needs it and the + open-source CLI's `os package publish` speaks it, so it STAYS, relocated to `src/marketplace/` + and published as `@objectstack/spec/marketplace`. Every def, key and JSON Schema is + byte-identical under the new `$id` category (`RENAMED_DEFS`, 32 entries; nothing left the + author-facing contract). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `import { PackageSchema, CreatePackageRequestSchema, … } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/marketplace'` — same symbols, same shapes | + | `import { EnvironmentArtifactSchema } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/system'` (it was only ever a re-export of that declaration) | + | `import type { EnvironmentType } from '@objectstack/spec/cloud'` | `… from '@objectstack/spec/api'` (re-declared beside the discovery fold table that reads it) | + | `import { EnvironmentSchema, TenantPlanSchema, ProvisionEnvironmentRequestSchema, … } from '@objectstack/spec/cloud'` | no open-source replacement — these are the cloud repo's own declarations now | + | `/docs/references/cloud/` | `/docs/references/marketplace/` for the format pages (redirected); the control-plane pages have no successor | + + Why the mis-binding hazard closes with this: `client.environments.*` keeps its erased `any` + deliberately (#11925/#12036), and the camelCase `Environment` row used to be the obvious-looking + binding for it — it compiled and read `undefined` at runtime against the snake_case wire. That + type no longer exists in the open-source package, so the wrong binding is structurally + impossible rather than warned about in a docblock. + + `@objectstack/cli` and `@objectstack/metadata` change only an import path (`marketplace` and + `system` respectively); no behaviour moves. +- 51efbf1: feat(driver-sql)!: a text operator over a column whose DECLARED type is temporal answers the type-gated no-match on every SQL face (#15683) + + + + **BREAKING** in the answer sense, on every SQL face, landing in the launch + window as `minor` under the lockstep convention this cluster's siblings use. + + **The behaviour that GOES AWAY, by name: searching a date as a string.** On the + SQLite family — `driver-sql` on any SQLite connection, `driver-sqlite-wasm`, and + `driver-turso`'s local transport — a `Field.date` / `Field.datetime` / + `Field.time` column stores canonical ISO TEXT (ADR-0053), and a text operator + matched that text. `{ signed_on: { $contains: '2026' } }` returned every 2026 + row; `{ made_at: { $startsWith: '2026-01' } }` returned that January's rows; + `{ shift_at: { $contains: ':30' } }` returned every half-past shift. **All three + now return nothing**, and their `$notContains` mirrors now return every valued + row. If you are relying on any of them, this is a row-set change and the + replacement is a range filter — spelled out below. The behaviour was never + declared by any contract row and it never worked outside SQLite: the same three + filters were a `DATABASE_ERROR` 500 on live Postgres. + + Nothing that was refused becomes admitted, and no new error code is minted — the + refusal reused is the one `NON_TEXT_STORED_VALUE_TYPES` already carried for the + numeric and boolean classes. + + Maintainer ruling, 2026-09-05 on #15683, quoted rather than paraphrased: + 「a text operator over a column whose DECLARED type is temporal is type-gated + exactly like the numeric and boolean classes; the SQLite ISO-text match is not + a contract」. + + ## What was wrong — one filter, three answers across one driver family + + `{ on_day: { $contains: '2026' } }` over a column declared `Field.date` holding + `2026-01-05`: + + | face | before | mechanism | + |:--|:--|:--| + | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local (SQLite) | **the row** | the column stores canonical ISO TEXT (ADR-0053), so `GLOB '*2026*'` matched it | + | `driver-sql` on live PostgreSQL 16.13 | **`DATABASE_ERROR` 500** | `operator does not exist: date ~~ unknown` (SQLSTATE 42883) — the same for `timestamptz` and `time` | + | `driver-sql` on MySQL | **NOT MEASURED** | no server was provisionable; reads as coercion via `CAST(col AS BINARY) LIKE` | + + Three answers to one filter, and no face declared which was canonical. The + SQLite answer was the accident of a storage form, not a capability: the same + query against Postgres was a 500. + + ## What it does now + + The three temporal classes join `NON_TEXT_STORED_VALUE_TYPES` + (`@objectstack/spec`), the set the SQL compilers consult at compile time + because the stored value is not visible until run time. Every face that reads + it — `SqlDriver` (and everything that inherits its compiler), + `driver-turso`'s remote transport, `service-analytics`' three SQL lowerings — + compiles the positive operators (`$contains` / `$startsWith` / `$endsWith` / + `$icontains` / `$like` / `$ilike`) to the FALSE constant and `$notContains` to + the TRUE constant. Postgres's 500 becomes that declared answer; complementarity + holds; the constants compose with the existing NULL-safe rules and the `$not` + rewrite unchanged. + + **The SQLite ISO-substring match is RETIRED.** A caller who was using it to ask + for "records in 2026" writes a range instead, which every dialect has always + answered the same way: + + ```ts + // before — matched only on the SQLite family, 500 on Postgres + { on_day: { $contains: '2026' } } + // after — the prescription, identical on every backend + { on_day: { $gte: '2026-01-01', $lt: '2027-01-01' } } + ``` + + ## Boundaries, so a reader does not over-read this + + - **A MULTI-VALUED temporal field is untouched.** `multiple: true` stores a JSON + TEXT array, where `$contains` is the MEMBERSHIP spelling #7398 left working on + a JSON column — not a substring test. It keeps compiling exactly as before. + - **The value-keyed JS evaluators do not move, and they DIVERGE — measured, not + caveated.** `driver-memory` canonicalises a declared temporal write to ISO + TEXT (#4047), for a `Date` input and a string input alike, so a positive text + operator MATCHES there — the exact complement of the answer this changeset + declares. That divergence is filed as #17348 and pinned by name in that + driver's conformance suite, alongside a correction: the two rows previously + read as pinning the no-match answer pass because their comparand omits the + milliseconds, not because anything type-gates. `formula` and `having` cannot + key on the declaration at all — `matchesFilterCondition(record, filter)` takes + a bare record ("this evaluator sees a bare record and has no schema to + consult", its own docblock), and `having` filters AGGREGATED rows whose columns + carry no field declaration. ⛔ So "on every face" is NOT delivered by this + change, and this changeset does not claim it: the SQL family answers the + declared rule, the JS faces do not yet. + - **`FILTER_TEXT_CASES` grows no temporal column**, deliberately. Every row there + is keyed on the STORED value — which is why its non-string column is a number + and not a date — so a temporal fixture would assert one stored form across all + five drivers that import it, the stored-form guarantee the ruling refused + option (b) for. + - **MySQL is NOT MEASURED**, not "passing": no server was provisionable, so its + cell rests on the compiled-shape pin, which reads the constant a statement + would carry without executing one. + +### Patch Changes + +- abc4b83: `search-fields.ts`'s module docblock says `$search` expands to an `$or` of `$icontains`, the operator the engine actually emits + + The docblock's ENGINE bullet claimed `@objectstack/objectql`'s + `expandSearchToFilter` expands a `$search` term into an `$or` of **`$contains`** + clauses. It has compiled to `$icontains` since objectstack#7641: + `packages/objectql/src/search-filter.ts:23` carries the ruling verbatim — *"The + case-insensitive operator is `$icontains`, NOT `$contains`. `$contains` is + contractually case-SENSITIVE (#4706 Q2 = A)"* — and both return paths of + `fieldClausesForTerm` (`:109`, `:111`) emit `$icontains`. + + **Why the distinction is worth a clause rather than a word swap.** `$contains` + is contractually case-SENSITIVE, so a reader who trusted the old sentence built + an ingress gate, a test or a driver **stricter** than the platform is — a false + refusal, not a leak. The corrected bullet now says that in one clause, so the + next reader of this module does not have to reconstruct it from two other + packages. + + ⛔ No behaviour changes. This is a module docblock; the engine has been right + since #7641 and no accept set, authorable key or published behaviour moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` ships `dist`, and + this TSDoc is emitted into `dist/data/index.d.ts` and `dist/data/index.d.mts` — + measured on the built artifact, with the old spelling absent from all 216 built + files afterwards and the docblock's own neighbouring sentence present at 2 as + the lit control. `src/data/search-fields.ts` is not a `.zod.ts`, so it is not + shipped as source; the emitted declarations are the whole of its published + reach, and they change. + + The sibling INGRESS sentence two lines below — `@objectstack/metadata-protocol` + `findData` refusing a `$searchFields` override the resolved set does not admit + (#4254) — was measured on the same tip and is unchanged: `findData` still calls + `assertSearchFieldsAreSearchable`, which resolves through this module's own + `resolveSearchFieldResolution` rather than re-implementing the rule. +- 245f360: `EventMetadata.cluster` and `ServiceMetadata.cluster` cite the live docs page by SITE URL, not a dead filename + + Both `.describe()` strings pointed at `cluster-semantics.mdx`, a page that is no + longer in the tree — `apps/docs/redirects.mjs` has redirected + `/docs/concepts/cluster-semantics` to `/docs/kernel/cluster` since the page was + folded in. The section numbers still resolved, so nothing was broken for a + reader following a link; what was broken is retrieval by filename, which finds + nothing. + + These two strings are the published half. `gen:docs` copies them into + `content/docs/references/kernel/events-core.mdx` and `service-registry.mdx`, and + they also ship as JSON Schema `description` values under `packages/spec/json-schema/` + and as string literals in `packages/spec/dist/`. So the citation had to become + something a SITE reader can follow: + + ``` + - See cluster-semantics.mdx §4. (a file that does not exist) + + See /docs/kernel/cluster §4. (the address the redirect already resolves to) + ``` + + ⛔ Deliberately NOT the in-repo house style. Source comments elsewhere in the + tree cite `` `content/docs/kernel/cluster.mdx` §N `` — a repo path, correct for a + reader who has the repo checked out. Copying that convention into a `.describe()` + would tell a docs-site reader to open a `content/docs/...` file they do not + have, which is the same class of unfollowable reference pointed the other way. + There is no in-repo precedent to copy either way: these are the only two + `.describe()` strings in `packages/spec/src` that cite a docs page at all. + + The site URL is also redirect-independent — it is the redirect's own target, so + the reference survives the redirect being retired. + + No accept set moves and no authorable key is added or removed: the schemas, + their parse behaviour and their exported types are byte-identical apart from + these two description strings. The two regenerated reference pages carry the + same one-line change on three rows. +- 324968e: The `translation-validation-messages-removed` migration text names the object-scoped bundle key, not just the authored literal + + `validationMessages` was retired in 17.0.0 (#4667). The ADR-0087 conversion that + migrates it told an author to author the message on the rule + (`object.validations[].message`) and stopped there. Since 17.3.0 (#14381, + #14253) that message has a translation route — + `objects.._validations..message`, resolved on the write + path — and the sibling prescription ten metres away in the same package + (`TRANSLATION_KEY_GUIDANCE.validationMessages`, the text the strict door + returns) already names it. + + ⛔ Nothing the old text said was false, and none of it is deleted. The defect is + **silence**: this is the *migration* text, read by exactly the population that + authored the retired key — the authors who wanted their rule messages + translated — and it steered them to a plain authored literal without mentioning + that the bundle key now exists. The literal advice stays; the route is added + after it. + + **Two texts in the file carried the narrow prescription, not one.** The + conversion's `summary` is the one the card named; the docblock above it asserted + that rule messages are *"not translated through a group"*, which would have sat + directly above the corrected summary. Both are completed. The docblock keeps its + 17.0.0 sentence — still true of the retired key — and says what 17.3.0 changed, + including why the object-scoped group is not `validationMessages` returning (the + retired one was keyed by rule name at the top level, could not tell two objects' + rules apart, and had no reader). + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `packages/spec/src/conversions/registry.ts` is not a + `.zod.ts`, so it is not shipped as source — but two published paths move, + measured on the built tree rather than reasoned about: + + - `dist` is in `files[]`, and the new sentence is emitted into six built files + (`dist/index.js` / `.mjs`, `dist/shared/index.js` / `.mjs`, + `dist/browser/index.js` / `.mjs`); a negative control string scored 0 on the + same tree. An author running `os migrate meta --from 16` reads the changed + notice out of that runtime string. + - `spec-changes.json` is itself listed in `files[]`, and it carries the summary + twice. It is generated (`gen:spec-changes`), and `check:generated` caught it + stale — the conversion registry feeds two generated artifacts, not one. + + `docs/protocol-upgrade-guide.md` is the third, regenerated with + `gen:upgrade-guide` and verified by `check:upgrade-guide`; all three are + regenerated, never hand-edited. + + ⛔ No behaviour changes. The conversion id, its `apply`, its accept set and its + fixture are untouched; no authorable key is added or removed. +- 7aae005: `ComponentPropsMap['object-grid'].exportOptions` names all five members the renderer reads, not two + + The entry is `z.unknown()`, so nothing about this key is parsed, refused or + stripped: a member that does not exist draws no error and has no effect, and a + member that does exist cannot be discovered from the schema. That makes the + `.describe()` string the entire account of the key's shape rather than a summary + of an enforced one — and it projects straight into + `content/docs/references/ui/component.mdx`, which is what an author (or a + generating model, ADR-0033) reads. + + It named two members, `formats` and `streaming`. The only renderer reads five. + + Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` + — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the + `schema.exportOptions` expression and the `exportConfig` local bound to it, with + objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose + comment/string stripping is what stops a prose mention of a key being counted as + a read): `formats` 2 read sites, `streaming` 2, `maxRecords` 1, + `includeHeaders` 1, `fileNamePrefix` 1, and an absent-name control + (`zzzNotAMember`) 0 on the same instrument — which is what makes those five + counts readings rather than a matcher that matches anything. The same instrument + answers the same five, with the same per-member counts, at objectui + `3fbdd4a2dae1`, so the set is not an artefact of the pin's age. + + The three missing members are `maxRecords`, `includeHeaders` and + `fileNamePrefix`. An author reading the old string learned that + `exportOptions` takes `{ formats, streaming }` and had no way to reach the other + three short of reading the renderer's source — the shape objectstack#8010 + closed for this same key one layer out, when `streaming` was read for releases + while no schema declared it. + + ⛔ The key is unchanged: it stays `z.unknown()` and no accept set moves in either + direction. Giving `exportOptions` a real shape is a separate and much larger + change with its own review requirements; this is the docs half only. + + The new list is not restated in prose that can drift on its own. A pin holds the + describe string's member enumeration equal to the members + `ListViewExportOptionsSchema` declares — the spec's own five-key declaration of + this same authoring block, reached through `ListViewSchema.exportOptions`'s + object branch and itself derived from that same read set. Both spellings reach + one renderer, so narrowing or widening the declared block now reds the + `z.unknown()` prose instead of leaving it quietly behind: the declared side has + parse failures to catch drift, this side had nothing. The pin also records that + the key is unvalidated today, so the day it grows an accept set is a deliberate + decision rather than a silent one. + + `content/docs/references/ui/component.mdx` is regenerated from the string + (`gen:schema` then `gen:docs`) and carries the same one-line change. +- 9e3c485: `date-macros.zod.ts`'s module header states the ADR-0053 D-D upper-bound rule the platform implements, instead of the rule it replaced + + The header's "Out of scope" block told an author that on a `datetime` column + `<= {current_year_end}` **stops at midnight on the 31st**, and prescribed the + half-open `< {next_year_start}` as the fix. That is the pre-ADR-0053 reading. + The platform rule has been the opposite since #3777: a bare `YYYY-MM-DD` used + as an upper bound denotes the WHOLE day, compiled half-open to the next + calendar day. It is stated once, in + `packages/spec/src/data/calendar-day.ts` (ADR-0053 D-D), whose own operator + table reads: + + | Operator | A bare `YYYY-MM-DD` on a `datetime` column means | + |---|---| + | `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | + | `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | + + and which `packages/spec/src/data/temporal-conformance.ts` pins cross-driver: + the case *"datetime: bare-day `$lte` keeps the whole final day"* expects + `d_mid` (09:15 on the boundary day) and `e_late` (21:40 on it) as members. + + **Why this header and not a note.** It is the doc comment on the vocabulary an + AI author reaches for, and it is the one place in the tree that says what a + `*_end` token does on the right-hand side of an operator. Both the old + prescription and the correct spelling parse, run and return rows, so nothing + downstream reports the mismatch — the author simply carries the wrong model + into every later filter. + + **What the correction does.** The load-bearing first clause is kept verbatim: a + `*_end` token IS the period's last calendar DAY. What follows now **cites** + `calendar-day.ts` rather than restating the rule, so the two statements cannot + drift apart again, and the half-open detour is refused by name for the reason + it is now wrong — the widening is already applied. + + ⛔ No behaviour changes. The diff is comment lines only; no schema, accept set, + authorable key or published payload moves. + + **This is shipped, which is why it carries a changeset rather than + `skip-changeset`.** `@objectstack/spec`'s published `files[]` lists + `src/**/*.zod.ts`, so this file ships verbatim as source, and the header is the + first thing in it. + + The generated reference page `content/docs/references/data/date-macros.mdx` + carried the same sentence — it is rendered from this header and is marked + AUTO-GENERATED — and is regenerated here with + `pnpm --filter @objectstack/spec gen:schema && … gen:docs`. +- 2eb4724: `ApproverType` qualifies `manager` in its `.describe()` instead of offering it as a bare allowed value + + `ApproverType` carried **no** `.describe()` at all, so the generated reference + page rendered `## ApproverType` with nothing but an `### Allowed Values` list: + `manager` — the one rung an author cannot operate on a stock install — read + exactly like the nine members that work. `{ type: 'manager' }` resolves + `sys_user.manager_id`, and that column still has no product write surface + (re-measured on this tree: the identity write guard's managed-update whitelist + for `sys_user` is `{name, image, locale}`; the column carries `readonly: true`; + no `packages/plugins/plugin-auth` source writes it). An author who chose it got + a chain that passed `validate` and `lint` and then stalled on its first + submission. + + The new describe says what is true about `manager` and **points** at the remedy + rather than restating it: `MANAGER_ONLY_REMEDY` / `MANAGER_ONLY_ROUTES` in + `packages/lint/src/validate-approval-approvers.ts` remain the single + authoritative copy of the population routes, and that file's `DEPENDENCY` + docblock now names this new string among the lines that go stale if the column + ever gains a write surface. A pointer cannot drift into disagreement with what + it points at, which is why no third copy of the 667-character remedy was added. + + ⛔ No member is added, removed or renamed, and no behaviour changes: the enum's + accept set is byte-identical and `check:api-surface` is green on the rebuilt + `dist/*.d.ts`. + + **Why this ships, and why `patch`.** `@objectstack/spec`'s published `files[]` + carries `dist`, `json-schema` and `src/**/*.zod.ts`, and the new string is + measured in all three on the built tree — `dist/automation/index.js` and + `.mjs` (2 files, against a lit control of an existing describe from the same + module, also 2), four `json-schema/` documents (`ApproverType.json`, + `ApprovalNodeApprover.json`, `ApprovalNodeConfig.json`, `objectstack.json`) and + the shipped `approval.zod.ts` source. Prose only, no surface widening ⇒ + `patch`. + + The `packages/lint` half is a docblock comment and is deliberately **not** + graded: that package publishes `dist` only, and the new sentence is absent from + it (0 files) while a runtime string from the same source file is present in 4 + and a pre-existing comment from the same docblock is absent in 0 — so comments + are stripped by construction and nothing published moves there. +- 0da638c: fix(analytics)!: every analytics face lowers the closed `dateRange` preset vocabulary to one window and refuses the rest with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16322) + + + + **BREAKING** for an in-process caller that reaches an analytics face PAST the + schema door with a string the closed vocabulary does not contain: it used to be + answered, and is now refused. Shipped as `minor` under the repo's launch-window + convention. The driver half of #16041, whose spec change closed + `AnalyticsQuery.timeDimensions[].dateRange`'s string arm to the thirteen + dashboard preset names; every value affected here was already refused at + `POST /analytics/query` and `/analytics/sql` when that landed. + + ## What was wrong + + #16041 closed the contract; the faces behind it never aligned, so the defect it + abolished simply moved onto the newly-blessed vocabulary. Measured on the built + `driver-memory` dist over five probe rows (2020, 2026-08-31, 2026-09-05, now, + 2099): + + | input | before | after | + |:--|--:|--:| + | `today` | 1/5 | 1/5 | + | the other twelve declared presets | **5/5 — 2020 and 2099 included** | a real window each | + | `'not a range at all'`, `'Last 7 Days'` | 5/5 | `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` | + + `driver-memory` recognised exactly `today`: every snake_case preset missed its + `startsWith('last ')` branch and fell to a `[range, range]` pseudo-window whose + two bounds were the preset's own NAME, which matched every `Date`-typed row + under BSON cross-type ordering. Both `service-analytics` SQL strategies lowered + the same names — and unrecognised strings, and `today` — to the point window + `created_at >= 'last_30_days' AND created_at <= 'last_30_days'`, whose answer is + whatever the dialect decides a vocabulary word compares as. So a dashboard + asking for one month got all of history on one backend and a nonsense + comparison on the other, at HTTP 200 on both. + + ## What it does now + + - **One lowering, in `@objectstack/core`.** `resolveAnalyticsDateRangePreset` / + `resolveAnalyticsDateRangeString` resolve every declared preset to + `{ start, end, endExclusive }`. The window is a pair of `{date-macro}` tokens + handed to the existing macro resolver, so `dateRange: 'this_month'` and a + `{month_start}` filter token cannot answer differently, and the anchoring on + `AnalyticsQuery.timezone` (#16042) plus the one-calendar arithmetic (#15825) + come from that resolver rather than from each face. + - **One refusal.** `analyticsDateRangeUnrecognizedError` stamps the ADR-0112 + envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` with the spec's own + `analyticsDateRangeRefusalMessage` wording — the same sentence the schema door + answers with. `driver-memory`, both SQL strategies and the draft-preview evaluator call + it, so "memory and SQL refuse identically" is one function rather than an + agreement. + - **The upper bound keeps #16179's separation.** A window a face RESOLVED is + compared exclusively (`$lt` / `<`) for the ten calendar presets and + inclusively for the three rolling `last_N_days`, whose bound is NOW; an + explicit `[a, b]` a CALLER wrote is untouched and keeps `$lte`. + - The fifteen `driver-memory` date-range pins #16041 retired are reinstated in + preset form (DST cells re-measured under calendar semantics, not re-spelled), + and one cross-face conformance fixture holds all FOUR faces to the same + windows and the same refusal. + - **The draft-preview evaluator is the fourth face**, and it is in that fixture + for the same reason the other three are. `preview-evaluator.ts` (ADR-0037 P3 — + the Live Canvas preview over a pending seed draft) carried the identical + `[range, range]` fallback, so a valid `last_30_days` selected NOTHING there, + silently, while the published chart beside it answered a real window — across + a publish boundary the preview exists to make continuous, since publish + materialises the same seed. + + ## FROM → TO + + Unchanged from #16041's — the spelling that is refused here is the spelling that + was already refused at the door. + + | you wrote | write instead | + |:--|:--| + | `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | + | `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | + | `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | + | `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + + The `@objectstack/spec` entry is a `PROVENANCE_WAIVERS` row only: the refusal's + code stays registered under `@objectstack/runtime` (the door that names the wire + vocabulary), and the waiver records that the shared constructor spelling it + lives one package over. +- 8a5240a: docs(spec): the `dashboard.widgets[].chartConfig` liveness row is re-anchored to the current objectui pin — 12 of 14 keys reach the renderer, not 9 (#17385) + + `packages/spec/liveness/dashboard.json` ships inside this package, so its rows are part of what an author reads. The `widgets.children.chartConfig` row was measured on 2026-08-09 against `objectui @230ffd875` and both halves of that reading are now superseded — re-measured by hand against this checkout's own `.objectui-sha` pin `53ded82bf7a4`. + + **The citation moved repos-internally.** `chartConfigPresentation` was lifted out of `plugin-dashboard` into `@object-ui/core`'s `chart-presentation` module, so the old pointer at `packages/plugin-dashboard/src/DatasetWidget.tsx:380-429` — still byte-exact at the commit it names — lands on the re-export block at that range in the pinned tree, while the nine `if`s it describes are in another package. A foreign path is counted and never resolved by `check:liveness`, deliberately, so nothing mechanical could have caught this: only a hand re-measurement does. + + **The count changed.** `xAxis` / `yAxis` / `series` were recorded as unforwarded on the grounds that they are derived from the dataset selection. They are forwarded today: the dataset keeps series MEMBERSHIP and the column each binding reads (`ChartSeries.name` and `ChartAxis.field`, dropped on the way through) while every other key on those objects merges onto the derived binding with the explicit binding winning. `type` and `aria` remain the two keys that do not reach this face. + + Evidence text only — no verdict moves, no schema key changes, and the row still carries no per-key `children`. The per-key drill, the `type` / `aria` dispositions and the authored-versus-derived precedence the protocol does not yet state stay open on #17385. +- c7af6bd: docs(spec): `options.stageOrder` no longer documents a chart type that cannot be built, and says plainly that only `funnel` reads it (#17344) + + `DashboardWidgetOptionsSchema.stageOrder` is an ungated member of the open widget `options` bag, so its one sentence of prose is the whole author-time surface: nothing warns, nothing refuses, and a widget carrying the key renders with the authored order simply absent. That sentence said *"Explicit category order for ordered-sequence charts — `funnel` / `pyramid` stages above all"*, and it was wrong twice over. + + - **`pyramid` is not a widget type.** It was removed from `ChartTypeSchema` as a variant that only ever rendered as `funnel`, and `chart.test.ts` pins that refusal alongside its fallback-only siblings — so the headline example in the option's own documentation could not be authored at all. + - **The plural framing promised more than the renderer delivers.** "ordered-sequence charts" and "stages above all" read as a statement about ordered marks generally. It is not one: `funnel` is the only type whose branch consults the forwarded order, measured against this repo's pinned objectui renderer. + + The corrected JSDoc and `.describe()` name `funnel` only, state outright that no other widget type reads the key, and send the other types to `sortBy` / `sortOrder`, which lower into the dataset query itself. The generated reference page (`content/docs/references/ui/dashboard.mdx`) is regenerated from the new `.describe()`. + + No schema shape changes: `stageOrder` still parses exactly as before, on every widget type. Whether the key should be *gated* to the type that honours it is ADR-0049 enforce-or-remove on an accepted key — a published-surface narrowing, and deliberately not this change; it stays open on #17344 together with the locale-dependent order/colour drop, which lives in the objectui renderer rather than here. +- 80aef80: fix(spec): the date-range preset prescriptions now name a one-day window for the one-day presets (#17014) + + `DATE_RANGE_PRESET_MACRO_WINDOWS` maps each dashboard date-range preset to the `{date-macro}` window a refusal PRESCRIBES to an author who wrote the preset name as a bare filter comparand (`bareDateRangePresetComparandMessage`). Two of its thirteen entries prescribed a window wider than the preset they name — a filter that parses, runs and returns rows over the wrong range, with no second error to correct against. + + - **`yesterday`** was `['{yesterday}', '{today}']` — an end naming the day AFTER the window. The pair is written for `$between`, which is `$gte min` and `$lte max`, and a bare-day upper bound means "through that whole day", compiled half-open to `< nextUtcCalendarDay(max)` (ADR-0053 D-D). So the prescription resolved to `>= yesterday 00:00 AND < tomorrow 00:00`: yesterday **and** today. It is now `['{yesterday}', '{yesterday}']`. + - **`today`** was `['{today}', null]`, the open `$gte`-only arm, so the prescribed filter had no upper bound at all and also selected every day after today on a column carrying future dates. It is now `['{today}', '{today}']`. + + Both entries now name their own last day, matching the convention the other eight closed entries already used and matching both executable mappings — objectui's `PRESET_RANGES` and `@objectstack/core`'s analytics date-range resolver, which independently spell `today` and `yesterday` as one-day windows. + + The convention that decides an end token was nowhere written down, which is what let one table carry two readings. It is now stated as a rule on the table: **`start` names the window's first calendar day and `end` names its last, inclusive — never the day the window stops before**, and `end: null` is the open arm reserved for exactly the three rolling `last_N_days` windows. Tests pin the resolved extent of every window against a frozen reference day and require a stated extent for every declared preset, so a preset added later cannot silently pick the other reading. + + No schema, type or export changes: the refused shapes and the vocabulary are exactly as before, and only the window text a refusal quotes back moves. +- 65ad77d: fix(spec): the driver-config registry refuses an off-vocabulary id instead of answering with a truthy non-schema + + `DRIVER_CONFIG_JSON_SCHEMAS`, `DRIVER_ID_ALIASES` and `DATABASE_DRIVER_ALIASES` + are plain object literals, so all three inherit `Object.prototype`, and every + lookup into them was a bare index. Measured against the built artifact + (`dist/data/index.mjs`) on the repo's Node 22 baseline (v22.22.2), an id that + names an inherited member resolved that member and was handed onward as if it + were a driver: + + | call | before | after | + |:--|:--|:--| + | `getDriverConfigJsonSchemaById('memory')` | the JSON Schema | the JSON Schema — unmoved | + | `getDriverConfigJsonSchemaById('constructor')` | `{}` — an EMPTY JSON Schema that accepts every config | `TypeError` naming the id and the legal vocabulary | + | `getDriverConfigJsonSchemaById('toString')` | `'[object Object]'` — a **string**, where the signature promises an object | `TypeError` | + | `getDriverConfigJsonSchemaById('valueOf')` | the registry object itself | `TypeError` | + | `getDriverConfigJsonSchemaById('__proto__')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `getDriverConfigJsonSchemaById('nope')` | `TypeError: … is not a function` | `TypeError`, now naming the id | + | `resolveDriverId('constructor')` | the `Object` **function** — truthy, not a driver id | `undefined` | + | `resolveDriverId('__proto__')` | `Object.prototype` — a truthy object | `undefined` | + | `resolveDatabaseDriverId('constructor')` | the `Object` **function** | `undefined` | + | `driverHasLocalDefault('constructor')` | `undefined`, out of a function declared `boolean` | `true`, as its doc promises for an unknown id | + | `resolveDriverId('pg')` / `resolveDriverId(' PostgreSQL ')` | `'postgres'` | `'postgres'` — unmoved | + + `getDriverConfigJsonSchemaById` handing back `{}` is the worst of these: an + empty JSON Schema validates anything, so a Studio connection form or a + `DriverDefinitionSchema.configSchema` consumer that asked "what shape must this + config have" was told "any shape at all" and reported success. + + The resolvers' half is reachable without a plain-JS consumer. The CLI refuses an + unclaimed operator selection with `if (driverType && !kind)` after calling + `resolveDatabaseDriverId`, so `OS_DATABASE_DRIVER=constructor` produced a truthy + `kind` that is not a driver id and walked past the refusal. + + All three lookups now go through an `Object.prototype.hasOwnProperty.call` check. + This narrows and widens nothing: every legal spelling is an own key of its table, + so no value accepted before is refused now, and only answers that were never + inside the declared return types move. The declared signatures are unchanged — + `getDriverConfigJsonSchemaById` stays `(id: BuiltinDriverId) => Record` + and both resolvers stay `(driver: unknown) => BuiltinDriverId | undefined`. + + A null-prototype table was the other available shape and was measured rather than + assumed: a `__proto__: null` object literal does not type-check against the + `Readonly>` annotation at all (TS2353), and the + `Object.assign(Object.create(null), …)` spelling that does compile silently costs + that annotation — a table missing a driver stopped failing to compile (TS2741). +- a54ecaa: feat(objectql)!: refuse a text operator aimed at a field whose DECLARED type can never store a string — `INVALID_FILTER` 400 at the engine's field-aware door (#15773) + + + + **BREAKING** for a caller that aims `$contains` / `$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike` at a numeric, boolean, temporal or structured-JSON field: the call used to be answered (with `[]`, with every row for `$notContains`, or with a dialect accident) and is now refused with `400 INVALID_FILTER`. Shipped as `minor` under the repo's launch-window convention. Execution lane (2) of the maintainer ruling on #15661 (decision batch #43, option C-deny); lane (1) is the contract it consults, `@objectstack/spec/data`'s `filter-text-operator-declared-type.ts` (#15804). + + ## What was wrong + + Measured on `origin/main` `59db8a02cb` with a real `ObjectQL`, the lane-1 fixture registered and a recording driver beneath — the filter reached the driver verbatim every time: + + | filter | before | after | + |:--|:--|:--| + | `{ f_number: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_summary: { $contains: '5' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_json: { $contains: 'a' } }` | driver read, `[]` | `400 INVALID_FILTER` | + | `{ f_date: { $startsWith: '2026' } }` | `400 INVALID_FILTER` — from the #8690 TEMPORAL door, about the COMPARAND | `400 INVALID_FILTER`, naming the field's declared type | + | `{ f_text: { $contains: 'a' } }` | driver read | unchanged — driver read | + + What the driver then answered is #14079's option-A row: no row for a positive operator, EVERY row for `$notContains`. Neither answer is wrong beneath the door — it is the declared answer — and neither carries any signal that the field can never hold a string, which is the cell this closes. + + ## What it does now + + - **One door, at the engine's single filter collection point** (`lowerWhereFilterArray`), third in the ladder: comparand shape (#5869) → materializable field (#8296 / #8371) → **declared type (this)** → temporal comparand (#8690). It runs before the temporal gate deliberately: a text operator over a `date` field was already refused there, with the same wire envelope but a message about the comparand, which sends the author to fix a value that could never have made the filter runnable. + - **The refused classes are DERIVED, never re-listed**: the verdict is `@objectstack/spec/data`'s `textOperatorDoorVerdict`, over `NUMERIC_VALUE_TYPES` ∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES` ∪ `STRUCTURED_JSON_TYPES`. A type added to any of those sets is refused with no change in this package. String-valued classes pass unchanged — `STRING_VALUE_TYPES`, `autonumber`, option codes (single AND multi, so `tags` keeps its substring filter), reference ids and the file classes. + - **No vocabulary is minted.** `INVALID_FILTER` already exists (`StandardErrorCode`) and is this package's filter envelope; the refusal carries `code`, `status` and `httpStatus` per ADR-0112 D5, and names the field, its declared type and the operator. + - **Both filter forms and every verb**: the object form and the `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus the per-aggregation `filter` position (#10576's second filter slot on `aggregate`) — a door that spoke on `where` alone would answer one mistake two ways within one verb. + - **Beneath the door nothing moves.** A direct driver call never passes this seam and keeps answering `FILTER_TEXT_CASES`' option-A row (#14079), as does `having` — both pinned. + + ## Deliberately unjudged + + - **A dotted key** (`f_address.city`) — `filter-dotted-head`'s subject, whose structured-JSON heads are deliberately unjudged there (#8371). The door steps over it rather than re-closing that carve-out. + - **An unknown filter field** — the engine keeps its registry-less tolerance; this door adds no second opinion about a name. + - **A registry-less host** (`schema.fields` absent) — a door that cannot see the field map invents no verdict, the same early return both neighbours make. + - **`formula`** — judged one door earlier. `assertFilterIsMaterializable` (#8296) refuses every filter over a `formula` field with `INVALID_FIELD` 400, for the broader reason that no driver materialises a column for it, so a formula's declared `returnType` is never the deciding fact at this seam. Not reordered around: that would answer ONE condition with TWO wire codes chosen by `returnType`. The divergence from lane (1)'s formula rows is pinned by name in `engine-text-operator-declared-type-door.test.ts` rather than dropped. + + ## The ADR-0087 ledger entry, and why this is `registered` rather than `not-required` + + `@objectstack/spec` carries one new semantic migration entry, `filter-text-operator-declared-type-refused` (protocol 18) — the `patch` bump above is that entry and nothing else; no schema, no export and no published set moved. + + It is a real registration because the refused shape has an AUTHORED, STORED surface, measured on the tree rather than assumed. Nothing rejects a stored filter at load — `FilterConditionSchema` constrains no field type, and `ViewFilterRuleSchema` takes `field: z.string()` with `contains` in its operator enum — so a filter body written before this change still parses, still loads, and answers `400` the next time it is executed. Carriers measured to reach this seam: + + | stored surface | how it reaches the door | + |:--|:--| + | `sys_saved_report.query_json.filter` | `report-service.ts` runs `engine.find(report.object_name, { where: q.filter })` verbatim; every `sys_report_schedule` row reaches the same body through `report_id` | + | `FieldSchema.summaryOperations[].filter` | `summary-aggregate.ts` ANDs it with the parent-FK match and calls `engine.aggregate` | + | `ListView.filter`, tab filters (`ViewFilterRuleSchema`) | `contains` / `not_contains` / `icontains` / `starts_with` / `ends_with` lower to the same operators through `AST_OPERATOR_MAP` | + | dashboard widget / `GlobalFilter`, dataset `filter`, report `runtimeFilter`, `FieldSchema.relatedListFilter` | `FilterConditionSchema` carriers, executed through the same engine seam | + + **Not** on that list, deliberately: an RLS / sharing / tenant predicate. Those are composed onto the AST by the middleware chain AFTER this door, so the door never judges one — a policy filter cannot become a 400 nobody can act on. + + No mechanical rewrite exists, which is exactly what a `semantic` entry is for: `{ amount: { $contains: '5' } }` may have meant `$eq: 5`, a range, or a different column, and `objectstack migrate meta` must not choose. The entry ships the repair procedure and its acceptance criteria instead. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `where: { amount: { $contains: '500' } }` | `where: { amount: { $eq: 500 } }` (or `$gte` / `$lte` for a range) | + | `where: { created_at: { $startsWith: '2026' } }` | `where: { created_at: { $gte: '2026-01-01', $lt: '2027-01-01' } }` | + | `where: { is_open: { $contains: 'true' } }` | `where: { is_open: true }` | + | `where: { address: { $contains: 'Berlin' } }` | filter a stored text field, or `where: { 'address.city': { $contains: 'Berlin' } }` (a dotted path stays unjudged) | + | `where: { tags: { $contains: 'urgent' } }` | unchanged — option codes are strings and still pass | +- 44c917a: The error-code ledger's TSDoc stops naming a retired verdict as a live mechanism, and states the published-face rule it is actually held to. + + `packages/spec` ships `src/**/*.zod.ts`, so `api/error-code-ledger.zod.ts`'s header is published prose — a consumer reads these sentences out of the tarball. Two of them stopped being true when `check-dispatcher-error-vocabulary`'s face refusal widened from `packages/spec/src/**` to every published package's `src/` and the dispatcher vocabulary's `boot-refusal` verdict retired with it (#16649). + + The first said the `boot-refusal` verdict **records** reachability for codes not yet registered, and pointed at the module the verdict was being deleted from. That is a claim about where a live mechanism lives, not about a case that can no longer arise, so a reader following the pointer would have found nothing. It now records the retirement and names what replaced it: a `door: 'none'` code has no resting place short of a row in the ledger. + + The second opened `packages/spec/src/** is held to this mechanically`. True before the widening and an understatement after it — a reader would conclude only the spec tree is guarded, which is the "guarded a part" / "guarded it" confusion this whole class of gate exists to remove. It now states the published face, the stricter spec sub-face where `pending-registration` has no allowance, and the named, dated allowance outside it owed to #8846, with both finding kinds named. + + No schema, accept set, default or refusal moves. `ERROR_CODE_LEDGER` holds the same members before and after, and the generated reference page is regenerated from this prose rather than hand-edited. +- 613d35a: The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, + instead of publishing an orphaned caption. + + `@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer + acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence + follows. Nothing asserted that one did. When a module header captioned a listing and wrote its + rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on + paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no + `remark-breaks`. Two customer-facing reference pages shipped that way. + + The assumption is now a precondition the generator checks before it emits anything. A module + description whose caption has no block under it fails the docs build with a message naming the + caption and the source-side fix, the way the renderer already refuses a heading it cannot + renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the + wrong page impossible instead of detecting it afterwards, and it is scoped to the population + the renderer actually renders — module doc blocks — rather than to every `@example` line in the + package. + + ⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly + what this renderer refuses to do, and what an author writes instead of a fence is not knowable + from the text. It asks only the question the contract already states: is there a block beneath + the caption? An author who wants those words as ordinary prose writes them without the tag. + + Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop + re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All + twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. +- 0ee32ed: fix(spec): `FieldSchema` no longer prescribes `required` for `notNull` / `not_null` — the flattened column-constraint spellings now name `storage: { notNull: true }` (#16867) + + Writing `notNull: true` (or `not_null: true`) on a field was refused — correctly — and then told to write `required` instead, via a rename row in `FieldSchema`'s alias table. `required` is the one key ADR-0113 exists to say is **not** the column constraint. `required`'s own description in the same file states the opposite of what the rename prescribed: *"NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`)"*. + + The failure mode was not the refusal — that fired, loudly, and did its job. It was the **remedy**: an author reaching for a NOT NULL column complied, wrote `required: true`, and received a nullable column plus a write-time gate, with nothing downstream to refuse it. The refusal read as though it had been satisfied. + + All three flattened spellings — `notNull`, `not_null`, and `storageNotNull`, which already carried the correct sentence — now get one prescription naming the real key: + + > physical column constraints live under `storage` — write `storage: { notNull: true }` (ADR-0113). There is no flat spelling of it: post-17 a column is NOT NULL because its author wrote that nested key, and for no other reason. It is NOT `required`, which is the WRITE contract (an insert must provide a value; an update may not null it out) and deliberately does NOT imply the column constraint — `required: true` alone leaves the column nullable. Write whichever of the two you meant, or both. + + Both halves are named on purpose: the defect being repaired is that the author cannot tell which of the two axes they are getting, so a prescription naming only the column half would have fixed the measured direction and opened the mirror-image one. + + **No accepted key moves.** `notNull` and `not_null` were refused before this change and are refused after it — a `guidance` / `guidanceSets` table decorates a rejection and never admits a key. Only the sentence attached to the refusal changed. `storage: { notNull: true }` parsed before and parses now; `isRequired` and `mandatory` are genuine spellings of the write contract, ADR-0113 moved neither, and both still rename onto `required`. + + One mechanical note for anyone repairing a table like this: the entry moved from `aliases` to `guidanceSets`, not to exact `guidance`. `aliases` is indexed by `aliasProbe` (case- and separator-folded, so one row covered `not_null` too) while exact `guidance` is matched case-sensitively on the authored spelling — a lone `guidance.notNull` row would have quietly dropped `not_null` onto the edit-distance fallback. The two spellings are pinned separately for exactly that reason. +- 58b36fa: fix(spec): project a union branch-by-branch, so five filter operators reach a published reference page + + `z.toJSONSchema()` refuses a whole schema the moment ONE node in it has no JSON + form, and `build-schemas.ts` applied that refusal per SCHEMA. `orderingComparandSchema` + is `z.union([z.number(), z.date(), z.string(), FieldReferenceSchema])`, so four + `data/filter.zod.ts` exports emitted nothing at all — and `$gt`, `$gte`, `$lt`, + `$lte` and `$between` reached no reference row. Not a blank Description cell: no + section. The ~2000 characters of `.describe()` on those slots — the #5685 comparand + contract, the #6571 endpoint contract, and the `{ "$gte": "2026-01-01" }` shape the + platform's own date-macro resolver produces — reached no reader. + + The generator now makes a third attempt when both strict directions refuse: it + projects with Zod's `unrepresentable: 'any'`, marks every node that came back with + no structural keyword, and DROPS the marked ones that are direct members of an + `anyOf` / `oneOf`. That is not a narrowing. These artifacts describe JSON + documents, a JSON document cannot carry a `Date` INSTANCE, so the set of JSON + documents that union accepts is unchanged by the drop. + + ⛔ A marked node anywhere else — an object property, a record value, an array item + — refuses the projection and the export is skipped with the message Zod threw, so + this cannot change WHY anything is skipped. Five exports leave + `unemitted-schemas.baseline.json` (23 → 18): the four filter exports, plus + `data/Hook`, whose only unprojectable member was the deprecated inline-function + handler branch — that puts 22 `data/Hook:` authorable keys under the key ratchet + for the first time. + + Published artifacts gain `json-schema/data/{ComparisonOperator,FieldOperators, + NormalizedFilter,RangeOperator,Hook}.json`, each carrying an + `x-unprojectable-branches` record naming exactly which branch the projection + dropped and where. +- d127f9b: `i18n.zod.ts` stops asserting a stale size for the inline-locale-map population. + + Two docblocks in this file each stated that the repo authors 31 inline locale maps — the + `INLINE_LOCALE_KEY` rationale ("Every inline map authored in this repo (31 of them, across + three platform pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no real + authoring surface") and the `I18nLabelSchema` form-2 note ("Three published platform pages + author 31 of these"). The measured population is 45: 33 in `sys-user.page.ts`, 6 in + `sys-organization.page.ts`, 6 in `sys-position.page.ts`. + + The number is **dropped** at both sites rather than corrected to 45. Neither sentence's + argument needs a magnitude. The first turns on the universal — *every* authored map uses those + four tags — so the accept set is what makes the constraint free, not the size of the set. The + second turns on the map being authored on published platform pages *and* resolved by + `pickLocalized`; one authored-and-resolved map already refutes "a convention the runtime + ignores", so the count was never load-bearing there either. Writing 45 would buy one release of + accuracy in prose that is cited as evidence for a schema constraint, and the figure has already + drifted once with nothing noticing; deriving it would mean a permanent gate whose only job is + keeping a number in a comment true. + + The measured half survives untouched at both sites: three platform pages author these maps, and + that is still exactly three. No schema arm, bound, default, `.describe()` string or export + changes; nothing an author can write is affected. +- c17b494: `id_field` now gets a named answer instead of a bare refusal: `FIELD_KEY_GUIDANCE` declares it a retirement with **no successor**, which is the spec-side fact objectui's ingestion choke point needs before it can canonicalise the key (objectui#7650 ruling A — retired spellings are folded once, at ingestion, never at the consumer). + + The direction was a factual finding, not a preference, and it went the way the cheaper branch happens to point — so here is the evidence rather than the verdict alone. A lookup stores the referenced record's id, and which field holds that value is not an authored per-field choice: the picker resolves record identity itself. Nothing on `FieldSchema` names it, nothing in `objectql` / `runtime` / `metadata-protocol` reads a per-field id key, and the two places the platform does let a reference be stored by something other than an id are declared elsewhere — `APPROVER_VALUE_BINDINGS.valueField` (per approver type, e.g. `position` routing by `sys_position.name`) and a seed dataset's `externalId`, the channel lookup references already resolve through. So there is no member to fold onto, and the prescription says what to reach for instead: `displayField` for the candidate's label, a dataset `externalId` for a portable natural key. + + **The entry is keyed `id_field`, in snake_case, and that is deliberate.** The two channels this table feeds disagree about the key face. A `to` becomes a `strictObject` alias, matched through `aliasProbe` — case folded, separators stripped — so one camelCase row covers every spelling. A `why` becomes strict guidance, matched exactly and case-sensitively on the authored spelling. A camelCase row would therefore never be reached by the key authors write, and every existing test in the file would still pass, because none of them asks whether an entry is ever consulted. + + That gap is closed too. Three assertions read the channel that actually answers an authored field key — `FieldSchema.safeParse`, since the schema is strict and the authoring-key walker stays silent on a strict surface by its own posture rule — and pin that the refusal carries this table's sentence verbatim, that a retirement suppresses the rename channel, and that the same-named `idField` on the `inlineColumns` GridColumn mirror is a different schema that stays live. +- c4d1759: docs(spec): record which axis the list-view calendar guard gates — and which it does not (#16577) + + `checkListViewCalendarVisualization` gates ONE way of asking for a calendar: `appearance.allowedVisualizations` includes `'calendar'`. A view can also ask for one by BEING one — `type: 'calendar'` — and that axis parses CLEAN at all three doors (`ListViewSchema`, `ObjectListViewSchema`, `VIEW_METADATA_MEMBERS.listOverlay`). The disposition was correct but undocumented, so it read as an oversight rather than a decision. + + **No behaviour changes.** Every parse verdict at every door is byte-identical before and after; the diff is a TSDoc block on the exported check (which ships in `dist/*.d.ts` and in `src/**/*.zod.ts`) plus pins in `view.test.ts`. + + What the docblock now records, all of it measured rather than inferred: + + - The `type:` axis is **not unwatched**. It is carried by `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` (`kernel/functional-completeness.ts`) at **warning** severity, under the same ADR-0078 §1 rubric this file's `page` note already cites — refuse what renders NOTHING, warn what degrades. The two doors have complementary coverage: the completeness check reads `type` only and is blind to `allowedVisualizations`; this check reads `allowedVisualizations` only and is blind to `type`. + - `viewType` is **not** a second spelling of `type`. The two authoring doors refuse it as an unknown key; the `.strip()`ed overlay write door (`PUT /api/v1/meta/view`) DROPS it, so the view parses as the defaulted `type: 'grid'` — an author who spells it reaches a grid, never a calendar. + + ⛔ Escalating the `type:` axis to a parse refusal is deliberately NOT done here: it would refuse a shape 17.3.0 accepts, which is a published-surface narrowing and belongs to a ruling — the same disposition the `timeline` scope pin has stated since #13817. +- f7a9740: The lookup-picker "who reads this" claims in `packages/spec` are re-measured against objectui and dated to the commit they were measured on. No schema, accept set, default or refusal moves — this is evidence prose, and every verdict it sits under is unchanged. + + Three claims had gone false, all in the same direction: they credited objectui's picker with reading a `snake_case` alias that objectui no longer reads. A stale *tolerance* claim fails in the dangerous direction — it tells an author a spelling is accepted downstream when it is not, so a value that will silently arrive as nothing looks supported by the spec's own prose. + + - **`liveness/field.json`, both `displayField` notes.** `/props/displayField` claimed the record picker "reads displayField || display_field"; `/props/inlineColumns/children/displayField` named the `snake_case` spelling flatly as *the* key the grid's lookup cells pass. objectui deleted that twin from `LookupFieldMetadata` with no deprecation window and no dual read. Both notes now name the read chain they actually have — `LookupField.tsx`'s `fieldMeta?.displayField || fieldMeta?.reference_field || 'name'`, and `GridField.tsx` handing the column's camelCase `displayField` straight through at all three lookup-cell call sites. Both entries stay `status: "live"`: `displayField` is live, and more exclusively so than the notes claimed. + - **`src/data/field.zod.ts`, the LOOKUP PICKER (forward) docblock.** It told authors that objectui's `LookupField` / `RecordPickerDialog` / `deriveLookupColumns` read "both these camelCase keys and their snake_case aliases" — a blanket claim over all seven keys declared beneath it. Measured, it holds for three: `lookupColumns`, `lookupPageSize` and `allowCreate` are each read as ` ?? `. The other four — `displayField`, `descriptionField`, `lookupFilters` and `dependsOn` — are read camelCase-only. The docblock now states that per key, keeps saying the truth for the three aliases that survive, and records that those three are objectui's own back-compat rather than a spelling this schema declares. + - **`liveness/field.json`, the `valueDomain` `evidence` string.** It described the shared membership predicate as one "the write path **will** call" while its own first clause already quotes the landed call site that calls it. Tense corrected; the pointer is unchanged. + + Each rewritten claim now names the objectui commit it is dated to, so a later reader can tell how old the evidence is instead of assuming it is current. That dating is prose by design: a gate over a pinned foreign tree would go stale at every pin bump and need its own anti-vacuity self-test, which is a worse trade than a dated sentence. +- 5f9f846: fix(spec): the one-app-per-package refusal cites the record it means, `ADR-0019 (app-as-consumer-unit) D3` + + `ADR-0019` names **two** records in this repository — `0019-app-as-consumer-unit` (D3 = a `type: 'app'` package defines at most one app) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`). Both have a D3, and `stack.zod.ts` cited the bare number for both, so an author following the refusal's own citation was as likely to reach the wrong decision record as the right one. + + The three citations of the app-cap rule now name the record: + + - the `STACK_SINGLE_APP_VIOLATION` message — the only one an app author ever sees; + - the `validateSingleApp` docblock; + - the `StackSingleAppViolationError` docblock. + + Only the message tail changed: `An 'app' package must define at most one app, but found N (…)` is untouched, so any consumer matching on that prefix is unaffected. The rule, the refusal's condition and `defineStack`'s behaviour are unchanged. + + The approvals-side citations are deliberately left bare — repo-wide ADR-number disambiguation is tracked separately. +- 5bf2330: Correct the `permissions` alias table's justification for `hosts`, and pin the two aliases nothing measured. + + `PluginPermissionsSchema` (`kernel/manifest.zod.ts`) curates three aliases — `filesystem` and `paths` point at `fs`, `hosts` points at `network`. The block's only comment said edit distance cannot reach any of them, and it sat directly above all three. That is true of the two `fs` entries and false of `hosts`. + + The fallback budget is `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), so a 5-character key gets 2, and `hosts` differs from the declared `hooks` by exactly 2. Measured against the real `findClosestMatches` with the alias table out of the picture: `filesystem` and `paths` return nothing, `hosts` returns `hooks`. So without the alias an author writing `hosts` is answered ``Did you mean `hosts` → `hooks`?`` — pointed at lifecycle hooks on the one block that also grants network access. + + The alias is therefore better justified than the comment claimed: it overrules a confident wrong suggestion rather than filling a silent gap. Only the justification moves — the alias stays, the declared keys, the strictness and the union are untouched, and no message an author reads changes. + + `hosts` is also the only one of the three whose absence would be invisible, since it is the only one that changes a live suggestion, so `manifest-unknown-keys.test.ts` now pins both it and `paths` alongside the `filesystem` pin that was already there, asserting the offending key and the rename — and, for `hosts`, that `hooks` is not what comes back. +- d9e1587: `PluginSchema.version` now describes the grammar it actually enforces instead of calling itself `"Semantic Version"`. + + The key's regex accepts **every** SemVer 2.0.0-valid string and, additionally, eight strings SemVer 2.0.0 forbids: + + | SemVer 2.0.0 rule | Strings this key accepts anyway | + |---|---| + | §2 — numeric identifiers MUST NOT include leading zeroes | `01.1.1`, `1.01.1`, `1.1.01` | + | §9 — prerelease identifiers MUST NOT be empty or carry leading zeroes | `1.0.0-0123`, `1.0.0-alpha..1`, `1.0.0-alpha..`, `1.0.0-.` | + | §10 — build-metadata identifiers MUST NOT be empty | `1.0.0+.` | + + **No accepted value moved, in either direction.** The regex is byte-for-byte what it was; the `describe()` string is what changed. The leading-zero half is older than the recent widening — the original `/^\d+\.\d+\.\d+$/` admitted `01.1.1` too, because `\d+` always has — so tightening the key to the official SemVer regex would refuse plugin objects that load today, which the ruling on this key forbids. With the accept set frozen, the only side of the declared/enforced pair still free to move is the claim, and the bare `"Semantic Version"` was the false half: it named a standard this key does not implement. + + The replacement states the shape an author can predict a verdict from — `major.minor.patch` with an optional `-prerelease` and an optional `+build` suffix — and disclaims the standard it exceeds rather than merely dropping the word. This follows `ManifestSchema.version`, which already spells `(major.minor.patch)` explicitly rather than leaning on "SemVer". + + **What consumers see.** The `description` on `version` in the shipped `json-schema/` tree and on the generated `kernel/plugin` reference page. No `pattern`, no `type`, no accepted or rejected value changes, so a tool that validates against this schema behaves identically. + + All eight forms are now pinned as **accepted** — in `packages/spec` (`plugin.test.ts`) and in `packages/core` (`plugin-loader.test.ts`, `plugin-contract-enforcement.test.ts`) — so the honesty is enforced rather than narrated, and a future edit that "corrects" the grammar to be standards-compliant fails those pins on purpose. + + `@objectstack/core` is deliberately **not** listed above. Its `PluginLoader` predicate was renamed `isValidSemanticVersion` to `isSemverShapedVersion` in the same change, for the same reason, but the symbol is `private` and package-internal: measured against the built `dist/index.d.ts`, `import { isValidSemanticVersion } from '@objectstack/core'` is TS2305 (no exported member) and `loader.isValidSemanticVersion` is TS2341 (private), while a public member on the same class compiles. Nothing published moves. +- 143c715: fix(spec): the `protection` block's unknown-key refusal now names the surface, lists the declared keys and suggests the rename (#16845) + + `ProtectionSchema` (`shared/protection.zod.ts`) was a bare `z.object({ … }).strict()` with **no error map**, so an unknown key inside a `protection:` block was refused with zod's own default text and nothing else: + + ``` + AgentSchema.safeParse({ name: 'a', protection: { lockk: 'system' } }) + ✗ protection: Unrecognized key: "lockk" + ``` + + `lockk` is one keystroke from the declared `lock`, and the author — human or AI, whose whole correction loop is the error text — was told the key was wrong and given no surface name, no declared-key list and no rename. The block is mounted on very nearly every authorable metadata type in the platform (objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools, skills), so that was the message everywhere a protection key was misspelled. + + It is now built with the `strictObject` helper — the same conversion #16328 made for the manifest `permissions` block — and answers: + + ``` + ✗ protection: Unrecognized key(s) on the `protection` block of this metadata item: `lockk`. + Did you mean `lockk` → `lock`? … The declared keys are `lock`, `reason` and `docsUrl`. + ``` + + Curated alongside it: prose-slot aliases (`description` / `message` / `explanation` / `lockReason` → `reason`), documentation-link aliases (`docs` / `link` / `url` / `href` / `helpUrl` / `documentationUrl` → `docsUrl`), a wrong-layer prescription for the field-level `readonly` / `readOnly` booleans (which map to a `lock` *policy*, not a boolean), and one prescription for the whole private `_lock*` envelope family. Two of those aliases correct a measurably **wrong** answer: the edit-distance fallback used to point `docs` and `link` — each two edits from `lock` — at the lock policy rather than at `docsUrl`. + + **Not a breaking change: the accept set does not move.** `strictObject(options, shape)` is `z.object(shape, { error }).strict()`, and a zod error map is consulted only for an issue already being raised, so it can neither admit a value that was rejected nor reject one that was accepted. Measured rather than argued — the same parse probe across the declared key set, every accepted input, and every rejection's issue `code` reads byte-identical before and after. +- de1a611: `AppPlugin` now supplies `SeedLoaderConfig.locale`, so the `Seed.locale` axis takes effect on the default boot path. + + The locale filter axis landed complete on the consumer side: the loader reads `Seed.locale`, composes it with `env` by conjunction, and names every dataset it drops. What it never had was a **producer** — no first-party call site passed `config.locale`, so `filterByLocale` returned its input on its first line and `dataset.locale` was never read at all. Authoring the key changed nothing. That is the same shape `Seed.env` spent releases in before framework#4704. + + - **The locale is resolved from the app's own `i18n.defaultLocale`** — the same envelope key, read the same way `loadTranslations` already reads it for `setDefaultLocale` — and threaded into all three `SeedLoaderRequest`s `AppPlugin` builds: the inline boot seed, the per-org replayer registered for tenant provisioning, and the dev hot-reload seeder. + - **An app that declares no locale sends no `locale` key at all**, rather than an `'en'` default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; defaulting would have turned a wiring change into a data change, silently dropping a `locale: ['zh-CN']` dataset on every stack without an `i18n` block. A blank or non-string `defaultLocale` is treated as absence for the same reason. + - **Resolved at the call sites, not inside `load()`.** The sibling `env` axis resolves itself in the loader off an ambient `NODE_ENV`; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis needs a real producer, which is what this change is. + + `SeedLoaderService#warnOnUnresolvedLocaleScope` **stays**. It is not a signpost for an unwired state that has now gone away: three of this repo's six seed-request builders are publish/install-time paths that are handed no stack config and still pass no locale, embedding hosts build their own requests, and a stack may declare no `i18n` block at all. Every one of those still reaches `load()` with locale-scoped datasets and no `config.locale`, and the warning is what keeps that loud instead of silently inert. + + The liveness ledger row `seed.locale` moves `experimental` → `live` with a `producer` pointer naming this wiring, and records which call sites supply the locale and which do not rather than claiming the frontier away. + + ⚠️ **Release-note reconciliation, for whoever compiles this release.** The sibling changeset `seed-locale-axis.md` (from the PR that landed the consumer half) states in the present tense that no first-party call site supplies `config.locale`, that the axis is inert on the default boot path, and that the liveness ledger records `seed.locale` as `experimental`. All three sentences describe the state that changeset shipped into, and **this change ends all three**. If both land in one release, the notes must read them in order — or fold them into one entry — rather than publishing the earlier state as current. ⛔ That sibling changeset is deliberately not edited here: it accurately records what its own PR did, and release notes are compiled centrally. + + ⛔ Out of scope, unchanged: rows already written under a different locale stay resident. Every seed is an `upsert` and the loader only writes, so switching a stack's locale on a non-empty database does not remove the other market's rows. +- db76982: `ai/solution-blueprint.zod.ts` publishes its own sentence again, instead of a list of the symbols it happens to export. + + The file always carried a real module header — ADR-0033 §4 plan-first authoring, and how the `apply_blueprint` tool expands each entry into a proper metadata body. But only a blank line separated that header from `const SNAKE_CASE`, and TSDoc's own attachment rule says a block belongs to the declaration it immediately precedes. The header-zone selector reads that rule back, so the header counted as the regex constant's documentation and was disqualified as the module's. Both generators then fell through to their export-list fallback, and the row published into the `objectstack-ai` skill index read: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Exports: BlueprintConditionSchema, BlueprintSummaryOperationsSchema, … + ``` + + A true statement about the file that says nothing about its subject — on the one row whose job is to send an agent to this source for exact field shapes. + + `SNAKE_CASE` now carries the one-line doc it always deserved. A comment is not a declaration, so the preamble ends there and the header becomes the module's own block. The published row and the public reference page both open on it: + + ``` + - `…/ai/solution-blueprint.zod.ts` — Solution Blueprint Schema (ADR-0033 §4 — plan-first authoring) + ``` + + The selector is untouched. Under its own rule it was deciding correctly, and a census of every source under `packages/spec/src` found this file to be the only one of its kind: 19 shipped `*.zod.ts` sources have a header-zone block sitting against a declaration, and in the other 18 that block genuinely documents the symbol it sits against (`Transport Protocol Enum` against `TransportProtocol`, `Shared history for this file` against `AGENT_HISTORY`). Only here did a module header sit against a constant it says nothing about. + + Neither generator can see this class — each compares its artifact against itself, and each reproduced the selector faithfully, so a generator-only check passes on the defect. A pin now asserts the content of the published row directly. +- ab450f4: docs(spec): `functional-completeness`'s three `objectql/engine.ts` citations name symbols instead of line numbers (#16960) + + The module doc block of `kernel/functional-completeness.ts` cited the runtime that + justifies each rule by line number. All three had rotted: re-measured on `origin/main` + `7ddf13dca` (`engine.ts` is 15,309 lines), the quoted texts live at 8630, 8978 and 921 + against cited 3001, 3191 and 346 — drifts of 5,629, 5,787 and 575. Each quoted text + occurs exactly once in `engine.ts`, so those are readings rather than artefacts. + + The citations are the only limb tying a rule's justification to the runtime that + implements it, and that limb is walked by a human reading it — nothing in the module can + notice the runtime moved. `:3191` was the dangerous one: the line it names today is + ordinary-looking `dispatch:` code, so a reader following it lands somewhere plausible and + never learns they were sent to the wrong place. + + Each now names the enclosing symbol in the repo-root `path#symbol` form + `packages/spec/liveness/field.json` already uses — + `packages/objectql/src/engine.ts#buildSummaryIndex`, `#planFormulaProjection`, + `#expandRelatedRecords` — beside the verbatim snippet. A corrected line number would rot + again on the next refactor; a symbol plus a unique snippet is greppable and survives + movement. The anchor form also moves these three from + `check-spec-docblock-symbol-anchors`' not-judged bucket into resolution (that gate now + reports `3 symbol (3 declaration)` where it reported `0`), so a rename reddens CI. + + Doc text only — no schema, export, type or runtime behaviour changes. It ships because + this block is emitted into the published `dist/kernel/index.d.ts`. +- 025588a: Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. + + The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. +- bbca441: `translateFlow` overlays screen nodes inside ADR-0031 regions, at any depth + + `translateFlow` (`system/i18n-resolver.ts`) read the flat `flow.nodes` array and + nothing else. But `FlowNode.config` carries ADR-0031 regions — + `loop.config.body`, `parallel.config.branches[].nodes`, + `try_catch.config.try`/`.catch` — each holding a full `nodes` array that nests + arbitrarily, and a `type: 'screen'` node inside one is a real screen: the + executor pauses on it and the client receives its `ScreenSpec.nodeId`. + + So `flows..screens..{title,fields.*}` was authored for such a + node, parsed (the bundle schema is keyed by node id and knows nothing about + depth) and was then silently never applied. The wizard step rendered its + source-locale heading and field labels while its siblings one level up were + translated. + + The descent now runs through `mapFlowNodeList`, a per-flow region-aware + copy-on-write walk shared with the ADR-0087 conversions' `mapFlowNodes`, which + reads `FLOW_REGION_SLOTS_BY_TYPE` — the single declaration of where a region + lives (`automation/region-slots.ts`). This resolver is therefore not a fifth + hand-rolled reader of that table; the fourth pass written against the flat + one-liner is the last one that had to be. + + Reference identity is unchanged and is pinned: a node that resolves nothing + comes back as the same reference, every container `config` and region `nodes` + array on the way down is copied only when a descendant actually changed, and a + flow the bundle does not carry is returned as the same object. + + ⛔ No wiring changed. `translateFlow` is still deliberately absent from + `translateMetadataDocument`'s dispatch table and no liveness row moved — that + decision belongs to the downstream runner card, as its docblock records. +- 7cd5874: docs(spec): the `field.valueDomain` liveness note stops claiming the settings door is "unchanged until then" + + The `valueDomain` row of the published `liveness/field.json` ledger ended on a sentence written + while the re-point was still in the future: + + > The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in + > its own follow-up card and is unchanged until then. + + Both halves of the 2026-09-02 ruling have since landed — the settings half (#15434) and the engine + half (#15316) — and the engine half rewrote this note wholesale while carrying that sentence + forward verbatim. "Unchanged until then" therefore described a state that no longer existed: the + door it names had already re-pointed, one commit earlier. + + The sentence now says what is true of that door, read off its source rather than off a PR title: + its second copy of all three definitions is deleted, `firstRejectedDomainMember` asks + `isValueDomainMember` — the same call `record-validator.ts` makes — and what remains on that side + is the door's own business (which declarations it agrees to enforce, how a multi-value carrier is + walked, the fragments the env-override log line needs). A re-added local table reddens + `value-domains.shared-predicate.pin.test.ts`. + + Ledger-note text only. The row's `status` is untouched — it tracks the engine write path, and + `liveness/state-counts.md` is derived by `gen:liveness-counts` from the row states, none of which + move here (`check:liveness` reports the counts file current). +- 7887077: fix(spec): stop advertising `app` as an expression-scope root the shipping renderer mounts (#17203) + + Six prose faces of the UI schemas told an author that a CEL predicate could name `app` — that the shipping renderer mounts it alongside `features` and `os.user`. It does not, and it never contractually did. `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and ADR-0068 has never ruled it; decision batch #67 (2026-09-07) ruled option B — the engine's `SCOPE_ROOTS` is the contract and ObjectUI aligns to it — and ObjectUI shipped that, so `buildExpressionScope` no longer binds `app`. The producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was closed `not_planned` in the same ruling. + + The `app` token is deleted from all six. `features`, `os.user`, `data`, `current_user`, `record` and `user` all stay, in place and in their existing order, and the "renderer behaviour, NOT contract-guaranteed" framing is unchanged: + + - `ui/page.zod.ts` — the "Ambient roots" docblock, and the **published `.describe()`** on `PageComponentSchema.visibleWhen`, which republishes verbatim into `content/docs/references/ui/page.mdx` (regenerated here). + - `ui/action.zod.ts` — the param-level `visible` docblock, and the **action-level `visible`** docblock, which stated the same claim unbackticked (`record/user/app/features`) and was invisible to a probe shaped for the backticked token. + - `ui/component.zod.ts` — the `page:tabs` ambient-root name-resolution example, and its "also mounts the ambient …" sentence. + + Why this was worth correcting rather than leaving to rot: this `.describe()` is the surface an authoring tool and a metadata-generating agent read (ADR-0033 lists AI as a primary consumer), and it was the last place anywhere that could still teach either to write `app.tier == 'pro'`. The resulting predicate does not fail uniformly and is silent both ways — a field `visibleWhen` and a nav / area `visible` fail OPEN (the gate stops hiding), a conditional-formatting `condition` and a row-action `visible` / `disabled` fail CLOSED (the rule silently stops matching). + + No accept set moves: `SCOPE_ROOTS` is untouched, every schema parses exactly what it parsed before, and a predicate naming `app` is accepted and rejected precisely where it was. This narrows what the protocol advertises, and nothing else. A pin test now holds all six faces, published and TSDoc alike. + ## 17.4.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 180e392f77..ef94bbfff9 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.4.0", + "version": "17.5.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 585a095c55..c48ce63509 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/trigger-api +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index d1c2d8ccd1..b27d885483 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.4.0", + "version": "17.5.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 683fb3781c..fb630b8b0f 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,77 @@ # @objectstack/plugin-trigger-record-change +## 17.5.0 + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index ccfed35ef2..041bc19ad7 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.4.0", + "version": "17.5.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 3efcff004c..0a4adc83fb 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,202 @@ # @objectstack/plugin-trigger-schedule +## 17.5.0 + +### Minor Changes + +- ecdfc94: fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659) + + + + **Registered as an ADR-0087 semantic migration** + (`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable + is renamed, retired or re-typed — no `packages/spec` key changes its name, its + type or its optionality, no stored shape moves, and every flow, node and + start-node `config` that parses today parses byte-identically afterwards, + because the start node's `config` is an OPEN record (ADR-0018) and the new + `organization` key is an addition to a slot that already accepted anything. So + `objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a + value only the deployment holds, a `sys_organization.id` minted at runtime, with + no authored artifact and no stored representation a rewrite could act on — and + inventing one is precisely what the ruling forbids. ⚠️ That is the argument + against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a + migration that cannot be expressed declaratively gets a structured TODO + (surface, reason, acceptance criteria) rather than nothing, and what follows IS + a prescription in that sense — declare `config.organization` once per + organization, no fan-out, then act on the three consequences of the split named + below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) — + behaviour-only, no shape moved, a deployment judgement no transform can make, + registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before + this narrowing landed, so the enforcement rides the 17.x line by the + launch-window convention while the prescription belongs at the major boundary + where `migrate meta` users look. + + **BREAKING** in the accept-set sense, and in TWO places rather than one — + landing in the launch window as `minor` on all four packages (the lockstep + convention: during the window the bump level is not the carrier, this banner and + the disposition above are). Nothing that was refused becomes admitted. + + 1. **Bind time.** A `schedule` or `time_relative` flow that declares no + `organization` is no longer armed. + 2. **Run time — the DATA PLANE.** A time-triggered run now carries a + `tenantId`, and a `time_relative` sweep now carries one on its own query. + Where a run previously read, updated and deleted across every organization, + it is now confined to the one it declares. + + ⚠️ **Read (2) as a narrowing that can stop something that was working**, because + it is one. Two shapes to plan for, and neither is hypothetical: + + - **A deployment running ONE time-triggered flow to cover ALL organizations must + now declare one flow per organization.** That is the ruling + (「不允许跨组织的定时任务」) and it is the whole point, but it is migration + work: there is no fan-out, and a sweep wanted in N organizations is N + declarations. Nothing detects the shape for you — the flow simply starts + seeing one organization's rows. + + ⚠️ **And the split has three effects the sentence above does not carry.** Each + is deployment work, and none of them is detected for you either: + + 1. **A NULL-organization row fans out N-fold.** The driver's scope is + `org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no + tenant column value stays visible to a *scoped* read — this PR's own + negative control fixture selects exactly that row under scope, on purpose. + After the split every `organization_id IS NULL` row in a swept object is + therefore matched **once per flow**: N runs, N notifications, each acting + as a different organization. Before the split it was matched once. ⇒ Either + backfill the tenant column on swept objects or declare the object + platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the + scope rather than multiplying under it. + 2. **The current window's dispatch claims are abandoned.** The dedup key + embeds the FLOW NAME — `schedule::` and + `time-relative:::` — so N differently-named + flows claim under N different keys. A window already delivered under the + old name can deliver again, once, under each new one. ⇒ Cut over at a + window boundary, or accept one duplicate window. + 3. **A run suspended before the upgrade is not retroactively confined.** + Resume rebuilds the run's context from `context_json` + (`suspended-run-store.ts`), and a row written before this change carries no + `tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills + it. Not a regression (that is how it already ran), but the banner would + otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight + suspended time-triggered runs, or accept that the tail of them is + unconfined. + - **On a SINGLE-organization install a time-triggered flow WAS delivering** — + the #8844 guard derives the only organization there — and after this change it + is unarmed at boot until someone adds one line. On `@objectstack/driver-sql` + that install loses nothing at run time once the line is added: the scope is + `org = :tenant OR org IS NULL` and its one organization is the only scope there + was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss + has no legal configuration.** That driver refuses *any* call handed a tenant + scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589) + — `find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` / + `bulk*` / `aggregate`, one call at a time, regardless of how many + organizations the install holds. So a time-triggered flow that touches + per-organization data on that driver is refused per call if it declares an + organization and unarmed at boot if it does not. The declaration is not what + breaks it — the driver has no row-level tenant isolation to offer either way — + but this change is what moves such a flow from the "no organization context at + all → served" case into the refused one. Multi-organization deployments use + `@objectstack/driver-sql`; a `driver-memory` install whose swept objects are + genuinely platform-global can declare them so (`tenancy: { enabled: false }`, + ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the + refusal on data that really is per-organization. + + A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization. + + Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 + + A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing. + + - **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not. + - **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below. + - **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it. + - **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it. + + **What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later. + + ⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them. + + **Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness. + + No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands. + + **What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered: + + - **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read. + - **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained. + + **The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [f03f6c7] +- Updated dependencies [cf79182] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/core@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 920006c94e..e14f3dbb86 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.4.0", + "version": "17.5.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack — 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 f17319ea77..1665171dcb 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,184 @@ # @objectstack/types +## 17.5.0 + +### Minor Changes + +- c3ebe4a: A producer-declared 5xx **refusal** now keeps its message on the wire, at every door that reads the declaration. + + `ApiErrorSchema.refusal` (`@objectstack/spec`) is the producer-side declaration that a 5xx is a deliberate refusal whose `message` is authored for the caller. Until now nothing read it: all three arms that withhold a declared 5xx's prose could tell only that the producer had declared a *status*, so a refusal and a driver fault were sanitised alike and every producer-declared 5xx refusal reached the caller as `"Internal server error"`. + + The read is one new function, `declaredRefusalMessage` (`@objectstack/types`), called by all three arms — `declaredServerFaultAnswer` and `resolveErrorResponse`'s 5xx passthrough in `@objectstack/rest`, and `errorResponseBase` in `@objectstack/runtime`. REST's logging follows the same field: a declared refusal is no longer logged as `[REST] Unhandled error`. + + **What changes for a caller.** A 5xx whose producer sets `refusal: true` beside a `status` (or `statusCode`) in the 500-599 band and a non-empty `code` now carries that producer's message, bounded exactly as a 4xx message is. The first live case is `GET /api/v1/meta/:type/:name/references` for an unanswerable target, whose ADR-0110 D3 sentence ("Ask the owning object instead: …") reaches an operator again. + + **What does not change.** Everything else, and the default is fail-closed: a declared 5xx that carries no `refusal` is withheld exactly as before, an undeclared 5xx still goes through the leak heuristic, and a rewrap that drops the flag is withheld as a fault. A refusal cannot buy leaky prose past `looksLikeInternalErrorLeak` either — the declaration says the prose is *addressed* to the caller, not that it is *safe*. + + **For producers.** Setting `refusal: true` on a thrown 5xx is opt-in and additive; a producer that does not set it is unaffected. Platform and driver code must never set it on a fault. +- 6e3462d: Host importer: a `link:` / `file:` install is now verified by the LOCATION the app declared, so a correctly linked package loads instead of being refused. + + The ESM fallback finder (`createHostImporter`) verifies the one directory it consults — `/node_modules/` — against what the host's own `package.json` declares. Until now it could only do that by NAME, and a `link:` / `file:` value promises no name, so the KEY stood in for one: a package linked exactly as the app asked, whose own manifest happens to be named something else, was refused with `declared-unresolvable` / `MODULE_NOT_FOUND`. Nothing was broken, and the only way out was to stop using a supported linking mode. + + Such a declaration does name something checkable — a directory — so the finder now checks that too: `realpath(node_modules/)` against `realpath(resolve(hostRoot, ))`, both sides canonicalised, compared exactly (no basename matching, no case folding). If they are the same directory, the host declared it and it loads. + + This is a second verification axis, not a looser first one. A directory the app declared neither by name nor by path is refused exactly as before, and the finder stays strictly tighter than the CommonJS resolution it backs up, which asks neither question. Unchanged: a plain version range licenses no path; an `npm:` alias is still checked by name; `github:` / tarball URLs and the bare `owner/repo` shorthand name no on-disk location, so they gain nothing; a package that publishes a `require` condition never reaches this fallback at all, so no load that succeeds today changes. + + Measured on pnpm 10.33: `link:` symlinks the key at the declared directory and verifies; a `file:` directory install routes through pnpm's virtual store (a copy), so it does not, and keeps today's refusal. The refusal's text now states what the location check compared instead of asserting a limit the finder no longer has. +- 5a95b0e: fix(types,metadata-protocol,metadata,cli): a stored operator record names the dialect again, not the driver's composed refusal + + Since the raw-SQL seam began declaring its own fault, `SqlDriver.execute()` no longer + lets the dialect's error out: it raises `code: DATABASE_ERROR` / `status: 500` with a + COMPOSED message that discloses neither the statement nor the diagnostic, and carries + the dialect error whole under a non-enumerable `cause`. That envelope is deliberate and + is unchanged here. + + What changed underneath it is what every consumer STORED. Each migration probe, backfill + and rename in `@objectstack/metadata-protocol` / `@objectstack/metadata` embedded + `error.message` into an operator-facing record, so those records began reading + + the database refused to run a raw statement + + where they used to read + + no such column: foo + + For a live console that costs nothing — the driver prints the statement and the dialect + text to its warn sink one line earlier. For a record read later it costs everything: + whoever opens a customer install's backfill result a week on never had that line, and the + dialect's words were unrecoverable for them. + + `@objectstack/types` now exports `operatorFacingErrorText(error)` — a depth-bounded walk + of the `cause` chain, shaped like the `matchesDriverError` beside it — and the thirteen + stored-record sites plus `os db clean`'s console line read through it: + + - `runtime-index-preflight` — the per-probe `detail` and the seam-failure fan-out; + - `seed-tenancy-backfill` — the `absent` detail, the organization-probe report and the + three per-object warnings; + - `partial-index-probe` — the `detail` both callers report (and its two module comments, + which stated the opposite of what happened); + - `migrate-env-id-to-project-id`, `migrate-project-id-to-environment-id`, + `migrate-sys-notification-to-event`, `drop-projection-tables` — the per-table `error`; + - `os db clean` — the `VACUUM failed` line. + + Two narrowings are part of the contract, not incidental: an UNDECLARED throw is returned + on its own message channel, its `cause` never walked, and a declared envelope that is not + the raw-path one — the typed read exits' terminal, which composes a different sentence — + is left exactly as it arrived. + + That message channel is deliberately NOT byte-identical to what the replaced expressions + computed. The RULE, rather than a catalogue of cases: an undeclared throw comes back as + `messageChannelOf(error) || String(error)` — the thrown value's own string `message`, the + string itself when a string was thrown, and `String(error)` when neither yields text. Every + difference from the replaced expressions follows from that rule, so read the rule and not a + list. Illustrations of it, not an exhaustive set: an empty-message `Error` reads its `name`, + which for a named subclass is that subclass's name rather than `Error` / `TypeError`; a + thrown non-`Error` reads its own text or `String(error)` where `(e as Error).message` read + `undefined`, and where `null` / `undefined` threw a `TypeError` out of the catch, so no + record was written at all and the operation aborted; an object carrying a NON-EMPTY string + `message` reads it where `err instanceof Error ? … : String(err)` recorded `[object Object]` + (one carrying an EMPTY `message` still reads `[object Object]`). A thrown EMPTY string reads + `''`, so this channel is neither always prose nor never empty. + + ## The levels, and why they are not uniform + + `@objectstack/types` takes **`minor`**: it is the one package here that grows a published + surface — `operatorFacingErrorText` is a new export, present in `dist/index.d.ts` and in the + export list. A purely additive widening takes at least `minor`. + + The other four take **`patch`**, because none of them widens anything: they are a bug fix in a + released package, which is exactly what `patch` is for. `@objectstack/driver-sql` is named + because this change moves its `src/**` — by one ADDED file, the `.test.ts` that pins the helper + against a real `SqlDriver.execute()` refusal. Its published `dist/` is byte-unchanged by this + PR: no entry point reaches a test file, and `files` packs `dist` only. + + **Not breaking, and deliberately not marked so.** Nothing is removed, renamed or made stricter: + what moves is the TEXT inside an operator-facing `detail` / `error` field, never a field name + and never a type. The change these sites were made for is the declared raw-path fault, where + the record gains the dialect's words in place of the driver's composed placeholder. Every + other throw now reaches these records through the rule above rather than through the + expression each site spelled out, so its text can move too — a consequence of the rule, not a + bounded list of exceptions. At thirteen of the fourteen sites the rule is the whole record, + and some shapes still record `''` there: a thrown empty string, a thrown empty array, and an + `Error` whose `name` and `message` are both empty are the ones measured. The fourteenth is + `seed-tenancy-backfill`'s organization probe, which keeps a `|| 'unknown error'` fallback on + top of the rule, so those same three shapes record `'unknown error'` there rather than `''`; + that fallback is deliberate — the site reads an empty value as "the probe did not fail" — and + whether it should go is tracked by #17167. The sentence being replaced is not a value any + consumer can have been parsing: it is an opaque human diagnostic. A consumer reading these + records gets the dialect's words back where it had been getting a placeholder. + +### Patch Changes + +- 288fe9c: `createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. + + A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. + + That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. + + Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here. +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [7aae005] +- Updated dependencies [9e3c485] +- Updated dependencies [2eb4724] +- Updated dependencies [5f392f0] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [80aef80] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [f8e5790] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [5f9f846] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [9165d5c] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [143c715] +- Updated dependencies [d2badf7] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [ecdfc94] +- Updated dependencies [de1a611] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [7cd5874] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + ## 17.4.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 66e360d19e..f73c29333b 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.4.0", + "version": "17.5.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 96655ce869..9a01a1ce4c 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,203 @@ # @objectstack/verify +## 17.5.0 + +### Minor Changes + +- 041d9fd: fix(service-analytics)!: `POST /analytics/dataset/query` asks the OBJECT-level read grant before it serves an inline dataset (#16645) + + + + **BREAKING** in the accept-set sense — an accept-set narrowing on a published + route — landing in the launch window as `minor` on all four packages (the + lockstep convention: during the window the bump level is not the carrier, this + banner and the disposition above are). Nothing that was already admitted + becomes refused **except** the requests `GET /data/` refuses today for + the same principal, which is the defect. Nothing that was refused becomes + admitted. + + `POST /analytics/dataset/query` now asks the OBJECT-level read grant before it serves an inline dataset, so the analytics door and `GET /data/` reach one admission verdict on every driver. + + The route accepts an inline dataset definition (`body.dataset`) from any authenticated caller. On a SQL driver the compiled statement ran through the driver's raw `execute()`, which is documented as a tenant-isolation bypass and which no middleware sits in front of — so the request reached the database having passed exactly ONE of the three read layers (the row scope, threaded since ADR-0021 D-C). A caller with **no grant of any kind** on an object received its row count, and with `dimensions` its grouped counts by any column, where the `/data` door answered `403 PERMISSION_DENIED` for the same principal on the same deployment. On the memory driver the identical request fell through to the ObjectQL engine, which applies all three layers in one place, and was refused. The exposure is not opt-in and an application cannot decline it: a deployment shipping 0 datasets and 0 dashboards has the identical surface, because the reachable slot is the inline definition rather than a declared one. + + **This change NARROWS what the analytics doors accept.** Requests that were already refused by `/data` are now refused by analytics too; nothing that was refused becomes admitted. "Fails closed" is a statement about a WIRED provider: a deployment with no `security` service registered keeps its previous analytics behaviour by design, because on that deployment `/data` carries no object-level gate either and the equivalence is what is being defended. + + - **`ISecurityService.canReadObject(object, context)`** (`@objectstack/spec`, optional) — the object-level half of a read, the sibling of `getReadFilter`'s row-level half. It exists because the two are not interchangeable: `getReadFilter` answers "which rows" and answers `undefined` — "no row restriction" — for a caller who may not read the object at all, so a door holding only the filter reads a caller with NO grant as a caller with NO restriction. Fails CLOSED. Absence is a defined state and its fallback is **not** "admit": a consumer composes the same verdict from `explain`, which is not optional. + - **`@objectstack/plugin-security` implements it** as the middleware's own read gate, arm for arm and in its order — the `isSystem` bypass, the "no permission sets resolved" skip, the #3545 fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and the ADR-0090 D10 delegator intersection — from the same primitives the middleware calls, and it is exposed on the registered `security` service. + - **`@objectstack/service-analytics` asks it once at the door**, for the base object and every joined object, **ahead of strategy selection**. Placement is the fix: two strategies each enforcing their own copy of three layers is the CAUSE of the divergence, not its remedy, so both strategies — and any strategy added later — inherit one verdict by construction. `AnalyticsServicePlugin` auto-bridges the new `admitObjectRead` hook to the `security` service (`canReadObject`, falling back to `explain`), the same way it already bridges `getReadScope`, and warns loudly at init when no security service is registered. The bridge tells three resolutions apart: an ABSENT `security` service admits (that deployment has no object-level gate on `/data` either, so the two doors still agree, and this is what keeps a deployment shipping no `plugin-security` working as before); a service that cannot be USED — resolving it throws, or it exposes neither `canReadObject` nor `explain` — DENIES and reports at `error`, because `/data`'s middleware does not fall open in those states. + - **`@objectstack/verify`** gains `bootStack(app, { databaseDriver: 'sqlite-wasm' | 'memory' })`, because a two-driver equivalence property cannot be measured on one driver — which is how the strategies were allowed to disagree. + + The refusal is `PERMISSION_DENIED` / 403, the same code and status the engine path already answers, and it names only the object the caller themselves named. +- 6058cb2: **Clause-②: yes** — new exported symbols on a published package (`bootStackOnce`, `isVerifyRefusal`, and ten new members on the `VerifyStack` every `bootStack` caller already holds), so the accept set a consumer writes against widens. Contract-review tier. + + Every `VerifyStack` now carries an **in-process handle** on the stack `bootStack` boots — a way to run a hook, a validation rule, a flow, an action, a seed or a read against the REAL engine and assert on what the engine did, instead of writing through HTTP and inferring from persisted rows, or rebuilding the engine's semantics in a test stand-in. + + New members on `VerifyStack` (the same object `bootStack` returns; `api` / `apiAs` / `signIn` / `signUp` / `stop` are unchanged): + + - `hooks.run(object, 'insert' | 'update' | 'delete', input, { as })` — one write through the engine's own door as the caller `as` (a bearer token from `signIn` / `signUp`). The bound hook chain, field defaults, declared validations and the SecurityPlugin middleware run inside it, in the engine's order, because this is the very call the REST data ingress makes. Returns what the engine returned; a refusal rejects with the engine's own error (`code`, `statusCode`). + - `validate(object, record, { as, mode? })` — the engine's dry-run validation pass (`ObjectQL.validate`), nothing written. + - `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` — the runtime's `/automation` trigger and resume routes driven in-process (no Hono, no socket): the caller's resolved identity is forwarded exactly as the route forwards it, and the engine's `AutomationResult` comes back (plus `flowName`, so the value hands straight to `resume`). A never-dispatched refusal or a failed run rejects with the route's ADR-0112 envelope. + - `actions.run(object, action, { as, recordId?, params? })` — the `/actions/:object/:action` route driven in-process, the one door carrying the whole action contract (ADR-0066 D4 gate, ADR-0104 param contract, subject-record load, trusted body context). Returns the handler's value. + - `seed(object, rows)` / `rows(object, where?, { as? })` — real ObjectQL writes (the platform's own seed-replay context) and reads (system-scoped, or as a caller under that caller's grants and RLS). + - `metadata.object(name)` / `objects()` / `items(type)` / `types()` — the booted `SchemaRegistry`, by its own singular type vocabulary. + - `tenancy()` — the `tenancy` service AuthPlugin registered (`posture`, `requestedPosture`, `isolationActive`, `degraded`). + - `contextFor(token)` — the dispatcher's own request-identity resolution, exposed so a test can drive any kernel service as a real caller. + + Also new: `bootStackOnce(config, opts?)`, a per-process memo of `bootStack` keyed on the `config` and `opts` object identities — the worker-scoped shared boot `packages/qa/dogfood` kept privately, promoted for suites that run many files under `isolate: false`. + + Exported types: `VerifyHandle`, `VerifyRefusal` (with the `isVerifyRefusal` predicate), `AsUser`, `FlowRun`, `FlowRunRef`, `EngineRow`. + + **Zero re-implemented semantics.** Every method is a thin facade over a door the kernel wired at boot; the handle assembles no `ExecutionContext`, orders no hooks, evaluates no permission. The package's own tests pin each method against the real service behind it (the PR's ablation record breaks each service in turn and shows only that method's pin going red), pin `hooks.run` against the REST write on the same row **and** the same refusal, and port one hotcrm exemplar (`opportunity_lifecycle`) onto `hooks.run` as the proof of ergonomics. + + No boot option was added: the tenancy posture a stack runs under is still chosen by `multiTenant` (the `--multi-tenant` option `os verify` already has) and read back through `tenancy()`. `os verify`, `runCrudVerification` and `runRlsProofs` are unchanged. + +### Patch Changes + +- Updated dependencies [abc4b83] +- Updated dependencies [7382c5d] +- Updated dependencies [ea2940d] +- Updated dependencies [245f360] +- Updated dependencies [324968e] +- Updated dependencies [e526556] +- Updated dependencies [216b066] +- Updated dependencies [86c5052] +- Updated dependencies [7aae005] +- Updated dependencies [cea85fd] +- Updated dependencies [9e3c485] +- Updated dependencies [1a25f4a] +- Updated dependencies [bea41f6] +- Updated dependencies [2eb4724] +- Updated dependencies [4c42fd1] +- Updated dependencies [76ddab7] +- Updated dependencies [344d475] +- Updated dependencies [5f392f0] +- Updated dependencies [40098a4] +- Updated dependencies [94c9302] +- Updated dependencies [0da638c] +- Updated dependencies [041d9fd] +- Updated dependencies [113050e] +- Updated dependencies [5d12b16] +- Updated dependencies [54b3d1d] +- Updated dependencies [634f23d] +- Updated dependencies [f03f6c7] +- Updated dependencies [ea4d164] +- Updated dependencies [cf79182] +- Updated dependencies [efa2533] +- Updated dependencies [a36b526] +- Updated dependencies [dd2fd20] +- Updated dependencies [92865f6] +- Updated dependencies [929d9e3] +- Updated dependencies [8a5240a] +- Updated dependencies [c1d54db] +- Updated dependencies [c7af6bd] +- Updated dependencies [1f0b565] +- Updated dependencies [23aa83c] +- Updated dependencies [357f499] +- Updated dependencies [3c557e2] +- Updated dependencies [80aef80] +- Updated dependencies [c3ebe4a] +- Updated dependencies [e66da5c] +- Updated dependencies [a900841] +- Updated dependencies [65ad77d] +- Updated dependencies [a54ecaa] +- Updated dependencies [854639b] +- Updated dependencies [0780e88] +- Updated dependencies [44c917a] +- Updated dependencies [613d35a] +- Updated dependencies [0ee32ed] +- Updated dependencies [2bed4c3] +- Updated dependencies [58b36fa] +- Updated dependencies [4792049] +- Updated dependencies [53ec0b1] +- Updated dependencies [71629a1] +- Updated dependencies [f8e5790] +- Updated dependencies [cefe068] +- Updated dependencies [d2c1d19] +- Updated dependencies [681871e] +- Updated dependencies [706ad0f] +- Updated dependencies [288fe9c] +- Updated dependencies [d127f9b] +- Updated dependencies [4bbf766] +- Updated dependencies [c17b494] +- Updated dependencies [96684bb] +- Updated dependencies [ab56ea3] +- Updated dependencies [9ca49eb] +- Updated dependencies [a016f08] +- Updated dependencies [6e3462d] +- Updated dependencies [c4d1759] +- Updated dependencies [f7a9740] +- Updated dependencies [3644fad] +- Updated dependencies [45c2cf9] +- Updated dependencies [0f38ab0] +- Updated dependencies [dfb42c5] +- Updated dependencies [9540590] +- Updated dependencies [ae6dcf6] +- Updated dependencies [9cdffbe] +- Updated dependencies [331a1a2] +- Updated dependencies [9788f1e] +- Updated dependencies [980dc78] +- Updated dependencies [5c8f5af] +- Updated dependencies [5f9f846] +- Updated dependencies [5a95b0e] +- Updated dependencies [ca31ff6] +- Updated dependencies [5d527f7] +- Updated dependencies [5bf2330] +- Updated dependencies [775e5ec] +- Updated dependencies [9165d5c] +- Updated dependencies [1c83ca2] +- Updated dependencies [9b9581b] +- Updated dependencies [9ca49eb] +- Updated dependencies [fb7d75f] +- Updated dependencies [d9e1587] +- Updated dependencies [07150b3] +- Updated dependencies [f3b28eb] +- Updated dependencies [fd5cff2] +- Updated dependencies [143c715] +- Updated dependencies [0ced0aa] +- Updated dependencies [5b5bd36] +- Updated dependencies [2e8e118] +- Updated dependencies [d2badf7] +- Updated dependencies [2a79726] +- Updated dependencies [d64bcb6] +- Updated dependencies [d4f5232] +- Updated dependencies [470746a] +- Updated dependencies [ac24458] +- Updated dependencies [7026141] +- Updated dependencies [cf6e0a1] +- Updated dependencies [ecdfc94] +- Updated dependencies [4280055] +- Updated dependencies [de1a611] +- Updated dependencies [e758131] +- Updated dependencies [db76982] +- Updated dependencies [3b1dab9] +- Updated dependencies [1555ed4] +- Updated dependencies [776d64c] +- Updated dependencies [ab450f4] +- Updated dependencies [025588a] +- Updated dependencies [8c9bd8f] +- Updated dependencies [4215417] +- Updated dependencies [51efbf1] +- Updated dependencies [bbca441] +- Updated dependencies [ab1c585] +- Updated dependencies [7cd5874] +- Updated dependencies [a2509d7] +- Updated dependencies [7887077] + - @objectstack/spec@17.5.0 + - @objectstack/service-analytics@17.5.0 + - @objectstack/service-automation@17.5.0 + - @objectstack/runtime@17.5.0 + - @objectstack/core@17.5.0 + - @objectstack/plugin-auth@17.5.0 + - @objectstack/rest@17.5.0 + - @objectstack/plugin-security@17.5.0 + - @objectstack/objectql@17.5.0 + - @objectstack/platform-objects@17.5.0 + - @objectstack/types@17.5.0 + - @objectstack/plugin-sharing@17.5.0 + - @objectstack/service-datasource@17.5.0 + - @objectstack/service-settings@17.5.0 + - @objectstack/plugin-hono-server@17.5.0 + ## 17.4.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index b15e9885f6..719abcb5e4 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.4.0", + "version": "17.5.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",