From 57796ce2b2bc899eb7d124367b74672fc7cf87ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:08:31 +0000 Subject: [PATCH] chore: version packages (rc) --- .changeset/pre.json | 298 +- examples/app-crm/CHANGELOG.md | 154 + examples/app-crm/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 172 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 187 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 138 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 195 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 131 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 131 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 131 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 2008 ++++++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 145 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 1053 ++++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 244 + packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 123 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 123 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 123 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 123 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 83 + packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 445 ++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 113 + packages/create-objectstack/package.json | 2 +- .../src/templates/blank/objectstack.config.ts | 2 +- .../src/templates/blank/package.json | 16 +- packages/formula/CHANGELOG.md | 134 + packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 1139 ++++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 246 + packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 169 + packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 8 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 461 ++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 151 + packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 1351 +++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 122 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 940 ++++ packages/platform-objects/package.json | 2 +- packages/plugins/driver-memory/CHANGELOG.md | 123 + packages/plugins/driver-memory/package.json | 2 +- packages/plugins/driver-mongodb/CHANGELOG.md | 288 ++ packages/plugins/driver-mongodb/package.json | 2 +- packages/plugins/driver-sql/CHANGELOG.md | 720 +++ packages/plugins/driver-sql/package.json | 2 +- .../plugins/driver-sqlite-wasm/CHANGELOG.md | 131 + .../plugins/driver-sqlite-wasm/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 122 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 124 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 124 + .../plugins/knowledge-ragflow/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 1020 ++++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 223 + packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 785 +++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 271 + packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 133 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 536 ++ .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 88 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 203 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 814 +++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 341 ++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 244 + packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 187 + packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 122 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 10 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 1130 ++++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 1577 ++++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 2 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 707 +++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 898 ++++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 124 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 123 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 123 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 345 ++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 454 ++ packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 161 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 123 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 123 + .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 126 + .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 132 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 132 + .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 175 + .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 123 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 629 +++ .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 4579 +++++++++++++++++ packages/spec/package.json | 2 +- packages/spec/src/kernel/protocol-version.ts | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 123 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 243 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 123 + .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 462 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 474 ++ packages/verify/package.json | 2 +- packages/vscode-objectstack/CHANGELOG.md | 2 + packages/vscode-objectstack/package.json | 2 +- 156 files changed, 30748 insertions(+), 87 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 1e94c0e538..1bd90199b7 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -80,5 +80,301 @@ "@objectstack/verify": "16.1.0", "objectstack-vscode": "16.1.0" }, - "changesets": [] + "changesets": [ + "action-alias-conflict-warning", + "action-execute-target-precedence", + "action-param-inline-lookup-reference", + "action-param-strict-unknown-keys", + "action-undoable-liveness-corrected", + "adr-0104-d1-media-strict-per-deployment", + "adr-0104-d1-value-shape-contract", + "adr-0104-d2-typed-action-handlers", + "adr-0104-d3-wave1-file-value-shape", + "adr-0104-d3w2-pr1-upload-complete-fileid", + "adr-0104-d3w2-pr2-file-read-resolution", + "adr-0104-d3w2-pr3-file-ownership", + "adr-0104-d3w2-pr4-governed-download", + "adr-0104-d3w2-pr5a-write-cutover", + "adr-0104-d3w2-pr6-backfill", + "adr-0104-deployment-migration-gate", + "adr-0104-design-doc-only", + "adr-0105-d8-delegated-admin-org-role", + "adr-0105-d8-issuance-resolves-issuer-grants", + "adr-0105-d9-cross-org-approver-targeting", + "adr-0105-d9-refuse-on-directory-less-types", + "adr-0105-group-posture-entitlement", + "adr-0105-group-tenancy-phase-0-1", + "adr-0105-group-tenancy-posture", + "adr-0106-metadata-fls-masking", + "agent-knowledge-alias-and-experimental-markers", + "agents-pd12-alias-retirement-path", + "aggregate-temporal-output", + "ai-agents-pending-actions-sdk", + "ai-namespace-expresses-real-surface", + "ai-surface-affinity-lint", + "ai-tool-registry-and-lint", + "ai-wildcard-to-zero", + "analytics-client-dispatcher-alignment", + "analytics-cube-gate-and-error-leak", + "analytics-effective-granularity", + "analytics-execute-aggregate-execution-context", + "analytics-label-read-scope", + "analytics-objectql-read-scope", + "analytics-order-by-display-label", + "analytics-read-scope-bridge-order", + "analytics-widget-query-options", + "api-exposure-failopen-observability", + "api-methods-derivation-contract", + "apimethod-enum-shrink", + "apimethods-batch-conformance-ratchet", + "app-metadata-reference-integrity-assessment", + "approval-action-hierarchy", + "approval-actions-translation-bundle", + "approval-actor-is-the-authenticated-caller", + "approval-approver-value-bindings", + "approval-attachment-descriptors", + "approval-dead-run-ordering-invariant", + "approval-dead-run-record-lock", + "approval-empty-position-admin-override", + "approval-lock-schedule-run-provenance", + "approval-participant-visibility", + "approval-pending-approver-groups", + "approval-status-mirror-names-the-actor", + "approvals-expose-lock-record", + "approvals-payload-labels", + "approver-live-record-3447", + "approver-value-sources-and-dead-slot-warning", + "array-form-triggertype-not-silent", + "attachment-read-visibility-real-filter-semantics", + "auth-route-ledger", + "auth-validationerror-4xx-mapping", + "authorable-surface-ratchet", + "authz-ledger-flow-runas", + "automation-client-resume-screen-flow", + "automation-resume-authority-gate", + "banner-dsn-connection-display", + "better-auth-1-7-0-rc-2-and-prod-dep-batch", + "better-auth-team-member-count", + "chartconfig-trim-zoom-clickaction", + "ci-cache-tier1-optimizations", + "ci-node-22-pin", + "ci-node-eol-guard", + "ci-performance-optimization", + "ci-test-completeness-guard", + "cli-json-pipe-truncation-sweep", + "client-actions-surface", + "client-keys-sharelinks-security", + "client-meta-automation-descriptors", + "client-packages-lifecycle", + "client-url-conformance-capstone", + "close-approvals-and-record-shares-gaps", + "close-sharing-rules-explain-search-gaps", + "close-the-eight-reports-rest-gaps", + "close-the-final-nine-rest-gaps", + "close-the-nine-metadata-rest-gaps", + "console-09c6a177bb4a", + "console-1bb77aa24514", + "console-2cb8d78e24ad", + "control-plane-guard-crossref", + "conversion-notice-channel", + "data-path-object-existence-gate", + "datasource-availability-observability", + "datasource-bound-connect-failfast", + "date-bucket-parity-gate", + "decision-outputs-surface-3447", + "default-datasource-declared", + "delegable-scope-read-surface", + "department-approver-env-wide-business-unit", + "deprecate-kernel-assignment-notifications", + "deprecated-alias-conflict-rules", + "dispatcher-returned-error-leak", + "docs-audience-first-ia", + "docs-fieldschema-extend-rot", + "dogfood-gate-cancelled-not-failure", + "dogfood-shared-boot", + "driver-connect-bound-and-reconnect-correction", + "driver-sql-logicalop-retention-note", + "driver-sql-or-branch-and-semantics", + "drop-dead-env-template-flag", + "drop-dead-list-templates", + "dropped-fields-bulk-graphql-client", + "empty-group-bucket-key-null", + "enforce-user-level-export-axis", + "engines-node-22", + "export-axis-opt-in", + "export-empty-result-header", + "expression-approvers-3447-p2", + "fault-edge-guard-containment", + "fault-edge-label-lint", + "field-conditional-required-fold", + "field-readonly-doc-preserveaudit", + "file-access-delegate", + "filter-context-tokens-gate", + "filter-logic-conformance-single-source", + "filter-tokens-runtime-resolver", + "fix-stale-scaffolder-changeset-refs", + "fix-unmounted-local-file-url", + "flow-error-object-serialization", + "flow-filter-collapse-and-write-path-tokens", + "flow-lookup-expand", + "flow-template-filter-position-severity", + "flow-template-lint-and-hydrate-guards", + "flow-template-paths-into-reference-integrity-suite", + "flow-trigger-unknown-event-lint", + "formview-buttons-defaults-live", + "govern-report-dashboard-liveness", + "govern-sys-member-writes", + "govern-webhook-liveness", + "group-key-read-shape", + "group-union-driver-scope", + "guard-refusal-chokepoint", + "historical-import-audit-docs", + "i18n-bundle-drift-sweep", + "i18n-coverage-ratchet", + "i18n-extract-check-flag", + "i18n-field-labels-emit-declared-shape", + "i18n-field-labels-shared-nested-derivation", + "i18n-gate-declared-labels", + "i18n-sso-scim-userposition-importjob-coverage", + "i18n-success-envelope-conformance", + "i18n-translate-platform-bundles", + "i18n-translation-item-shape-3778", + "i18n-translations-request-drop-phantom-filters", + "ihttpserver-contract-codify", + "import-historical-audit", + "import-historical-fsm", + "import-sanitize-row-errors", + "import-undo-preserveaudit", + "index-drift-migrate-plan", + "invitation-accepted-host-seam", + "isLikelyEmail-no-control-char", + "job-retry-timeout-3494", + "lazy-deps-dist-probe-timeout", + "lint-flag-record-change-trap", + "lint-reference-integrity-suite", + "lint-translation-reference-integrity", + "list-column-prefix-summary-object", + "liveness-evidence-path-resolution", + "liveness-ledger-ai-scope-honesty", + "liveness-register-orphan-proofs", + "liveness-ten-preview-claims", + "liveness-verified-at-clock", + "manifest-bridge-arm-on-project-kernels", + "marketplace-objects-bridge-metadata-service", + "marketplace-rehydrate-seed-heal", + "membership-grade-not-capability-channel", + "metadata-unresolvable-posture-fail-closed", + "mongodb-single-tenant-boot-guard", + "naming-drift-recheck", + "nav-access-lint", + "notifications-redos-fix", + "objectchart-aggregate-result-columns", + "objectchart-contract-back-to-spec-shape", + "objectql-crossobj-capability", + "objectql-crossobj-fail-closed", + "objectql-driver-connect-failfast", + "objectql-strategy-daterange", + "osv-batch-2026-07-dep-bumps", + "page-field-and-chart-binding-lint", + "page-header-i18n-3589", + "platform-objects-app-i18n-phantom-debt", + "plugin-page-i18n-drift-guard", + "preserveaudit-test-and-docs", + "previous-null-on-create-leg", + "prose-example-gate-covers-docs", + "prune-aspirational-config-3494", + "prune-dead-audit-config-cluster", + "prune-dead-capabilities-descriptor", + "prune-field-prune-orphan-schemas", + "prune-orphan-featureflag-schema", + "prune-portal-schema-3464", + "prune-report-aria-performance", + "prune-report-column-grouping-schemas", + "prune-skill-permissions", + "purge-webhook-delivery-i18n-and-bundle-ownership-guards", + "rbac-objects-bulk-primitive", + "readme-fde-audience", + "readonly-flow-write-json-warning", + "readonly-flow-write-lint", + "ready-probe-driver-health", + "reconcile-packages-post-and-ui-view-dialect", + "record-after-write-trigger", + "record-change-hydrate-formula-fields", + "reference-integrity-object-and-action-names", + "regenerate-ui-action-reference-doc", + "reject-body-on-non-script-action", + "release-hotcrm-gate-premode", + "remove-dead-client-surfaces", + "remove-dead-sdk-surface", + "remove-enable-trash-mru", + "remove-graphql-surface", + "report-chart-dataset-describe", + "rest-env-resolution-kernel-resolver-seam", + "rest-patch-data-dropped-fields", + "rest-route-ledger-audit-guard", + "resume-gate-map-chain-and-reserved-vars", + "resume-signal-chokepoint", + "retire-default-dispatcher-routes", + "retire-three-deprecated-aliases", + "route-audit-tranche-3-service-mounts", + "route-ledger-audit-guard", + "runtime-action-execution-module", + "runtime-actions-mcp-extraction", + "runtime-auth-ai-extraction", + "runtime-automation-extraction", + "runtime-domain-body-extraction", + "runtime-domain-extraction-batch3", + "runtime-domain-handler-registry", + "runtime-meta-data-extraction", + "runtime-packages-extraction", + "runtime-share-links-extraction", + "scim-provider-key-and-sso-scim-parity", + "scoped-invitation-placement", + "screen-field-visible-when-on-the-wire", + "security-get-readable-fields", + "security-props-liveness-recheck", + "security-service-contract", + "seed-datasets-multitenant-replay-union", + "seed-insert-replay-lint", + "seed-loader-composite-external-id", + "seed-loader-engine-schema-fallback", + "seed-state-machine-lint", + "seed-summary-banner", + "seed-summary-marketplace", + "seed-writes-exempt-state-machine", + "serve-fallback-declared-default", + "service-error-envelope-conformance", + "service-storage-success-envelope", + "sharing-access-level-full-removed", + "sharing-rule-recipient-reconcile", + "sharing-rule-unknown-sort-and-stale-help", + "showcase-action-disabled-specimen", + "showcase-nav-affordance-specimen", + "sql-driver-dialect-connect-timeout", + "sqlite-datetime-date-bucket", + "startup-log-noise-cleanup", + "step2-metadata-protocol-plugin", + "step2-prc-single-source", + "storage-download-filename", + "sys-view-definition-default-open", + "tool-requires-confirmation-not-enforced", + "tool-requires-confirmation-removed", + "two-factor-lockout-and-object-translations", + "two-factor-lockout-extension", + "two-factor-lockout-follows-settings", + "typed-decision-outputs-3447", + "unique-tenant-scoped-materialization", + "update-record-dropped-field-warnings", + "url-field-accepts-relative-urls", + "user-less-run-data-ops-refused", + "user-level-export-axis", + "v17-dissolve-protocol-alias", + "v17-rc-anchor", + "validate-runs-build-authoring-lints", + "verify-multitenant-requests-isolated-posture", + "webhook-authoring-surface-bridge", + "webhook-liveness-ledger-flip", + "webhooks-drop-dead-delivery-i18n", + "withdraw-adr-0107-drop-writes-proposal" + ] } diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 7457e58abb..e18bf43b67 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,159 @@ # @objectstack/example-crm +## 4.0.92-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + ## 4.0.91 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 0b85ea68ea..64d06256b9 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.91", + "version": "4.0.92-rc.0", "description": "Minimal CRM example — a smoke-test workspace that exercises the metadata loading pipeline (objects → views → app → dashboard → hook → flow → 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-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index feea567b4d..c8e6f9680a 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/example-showcase +## 0.3.14-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [cf5e033] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [402f534] +- Updated dependencies [1c8bf4f] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [5cfd4d5] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [647ec8b] +- Updated dependencies [7457a09] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/driver-sql@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/service-datasource@17.0.0-rc.0 + - @objectstack/cloud-connection@17.0.0-rc.0 + - @objectstack/connector-mcp@17.0.0-rc.0 + - @objectstack/connector-openapi@17.0.0-rc.0 + - @objectstack/connector-rest@17.0.0-rc.0 + - @objectstack/connector-slack@17.0.0-rc.0 + ## 0.3.13 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 4a7d789dfa..2e3c3f9bdd 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.13", + "version": "0.3.14-rc.0", "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md index 727245607b..bb8e06ffab 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,192 @@ # @objectstack/example-todo +## 4.0.92-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [984396b] +- Updated dependencies [8f9689f] +- Updated dependencies [0cdb57a] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [1b717e5] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [094fa34] +- Updated dependencies [5e55739] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [4e9e184] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [a137bbc] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/client@17.0.0-rc.0 + - @objectstack/mcp@17.0.0-rc.0 + - @objectstack/metadata@17.0.0-rc.0 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.0 + - @objectstack/knowledge-memory@17.0.0-rc.0 + - @objectstack/service-knowledge@17.0.0-rc.0 + ## 4.0.91 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index 5b93fa3a8c..c11fbeac9a 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.91", + "version": "4.0.92-rc.0", "description": "Example Todo App using ObjectStack Protocol", "license": "Apache-2.0", "private": true, diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md index faaa209fb8..26e8f08c65 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,143 @@ # @objectstack/example-embed-objectql +## 0.0.32-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/driver-memory@17.0.0-rc.0 + ## 0.0.31 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 1879006109..cc02a45c00 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.31", + "version": "0.0.32-rc.0", "private": true, "description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).", "type": "module", diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 92ceec2fb3..8876a06a2e 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/hono +## 17.0.0-rc.0 + +### Patch Changes + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- cbedd62: fix(runtime,hono): close the remaining raw-driver-message exits on the HTTP boundary (#3867 follow-up) + + #3867 sanitised `dispatcher-plugin`'s `errorResponseBase`. That covers errors + **thrown** out of `dispatch()` — but not the ones it **returns**. A + `{handled: true, response}` result goes to `sendResult`, never through that + catch, and those bodies are built by `HttpDispatcher.error()`, which passed the + message through verbatim. Sweeping the boundary for the same defect class (the + follow-up #3867 called for) turned up two more live exits: + + **`HttpDispatcher.error()`** — the single construction point for every returned + error response. Reachable with a raw driver message today through + `errorFromThrown` (`/meta` save, `/packages` install) and the MCP transport's + `deps.error(err?.message, 500)`. Pinned by a test that drives + `PUT /meta/:type/:name` with a throwing `protocol.saveMetaItem`: without the + guard the response body is the driver's `insert into \`sys_team\` … UNIQUE + constraint failed: sys_team.id`, naming a physical table and column. + + **`@objectstack/hono`'s auth-config route** — a 500 built from a caught + error with `message: err.message`. The auth service reads from the database, so + that message can carry a driver dump. + + Both apply the same `looksLikeInternalErrorLeak` predicate #3867 put in + `@objectstack/types`, and both are scoped to **5xx** for the same reason: a 4xx + message is a deliberate business/validation answer (`Path must be +/actions/:object/:action`, a hook's own `throw`, a `saveMetaItem` field error) + and must reach the caller intact. Structured `details` — the semantic `code` and + per-field `issues` the Studio maps back to inputs — is never touched, so a + sanitised 500 still carries everything a client can act on. + + Diagnostics are unaffected: callers that threw still hand the original error to + `errorReporter` via `__obsRecordedError`, and every 5xx is logged server-side. + + Audited in the same pass and deliberately left alone: the inline error bodies in + the `ai` / `mcp` domains (static literal strings, no interpolated error text) and + `plugin-hono-server`'s 403s (4xx, deliberate messages). With this change every + dynamic message on both dispatcher exits and the REST data routes goes through + one predicate. + +- c2d9098: feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + + Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already + surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` + #3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did + not — the same strips happened silently on every batched row — and the typed + client warning + CORS mirror were deferred. This closes those out. + + **Bulk passthrough (metadata-protocol).** + + - `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. + - `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. + - `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. + - **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + + **Contract (spec).** `BatchOperationResultSchema` gains an optional per-row + `droppedFields` (covers `updateMany` + `batch`, which alias + `BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional + aggregated `droppedFields`. Both are omit-when-empty, so existing clients are + unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for + batches — one response header cannot express per-row drops, so the per-row body + field is the canonical bulk channel. + + **Typed client warnings (@objectstack/client).** `CreateDataResult` / + `UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body + channel a type instead of an untyped property. + + **CORS (@objectstack/hono, @objectstack/plugin-hono-server).** + `x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` + allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin + browser can read the single-write drop header. The body `droppedFields` remains + the primary, cross-origin-safe surface — this is a convenience mirror. + + **GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, + but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and + `handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There + is no schema generator or mutation resolver to expose a typed payload field on, + so there is nothing to wire until a GraphQL engine lands — at which point the + protocol-layer `droppedFields` is already present and only the GraphQL schema + projection would remain. + +- Updated dependencies [af5a224] +- Updated dependencies [879ea13] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [19e3e6e] +- Updated dependencies [cbedd62] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [9613396] +- Updated dependencies [4ed7ed4] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [41642b0] +- Updated dependencies [394b7a1] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [030125b] +- Updated dependencies [8e08bc3] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [810a3a2] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [e231abb] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/plugin-hono-server@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index d7c9e275e1..0150ebf950 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "16.1.0", + "version": "17.0.0-rc.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 6fbeb49534..876e6415f1 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,136 @@ # @objectstack/account +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index d1e80064b9..a1f7ddb83a 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "16.1.0", + "version": "17.0.0-rc.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 f3ee6708f6..925c6b3f82 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,136 @@ # @objectstack/setup +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 2ab95b4c61..731b6d8b86 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "16.1.0", + "version": "17.0.0-rc.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 9852354dc4..d683df1558 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,136 @@ # @objectstack/studio +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 45fea0e7ce..eb713903cd 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "16.1.0", + "version": "17.0.0-rc.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 6176b1ad13..eb1ecdc494 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,2013 @@ # @objectstack/cli +## 17.0.0-rc.0 + +### Major Changes + +- e47b342: feat!: require Node.js 22 — promise the runtime we actually test (#3825) + + Every published package declared `engines.node: ">=18.0.0"`. **Node 18 reached + end-of-life on 2025-04-30 and Node 20 on 2026-04-30**, so the compatibility + promise covered two runtimes nobody patches — and, after #3830 moved CI to Node + 22, two runtimes nothing in this repo verifies. + + That left the promise and the evidence with **no overlap at all**: + + | | Node version | + | ----------------------------------------------------------------------------------------------- | ------------ | + | What CI validates every PR on | **22** | + | What `release.yml` publishes from | **22** | + | What every shipped Docker image runs (`docker/Dockerfile`, `blank` template, self-hosting docs) | **22** | + | What `engines.node` promised users | **>=18** | + + `engines.node` is now `>=22.0.0` across all 50 manifests. This is the honest + floor: it is the only runtime the packages are built, tested and shipped on. + + ## Migration + + **If you are on Node 22 or newer, nothing changes.** Node 24 (Active LTS since + 2025-10-28) and Node 26 both satisfy the new range. + + If you are on Node 18 or 20, upgrade to Node 22+. Both are past end-of-life and + receive no security patches: + + ```bash + nvm install 22 && nvm use 22 + ``` + + npm and pnpm surface an unsatisfied `engines` as an **`EBADENGINE` warning**, not + a hard failure, so an existing install will not break the moment you upgrade — + but the package is no longer tested on that runtime, and the failures are the + kind that do not announce themselves. #3812 is the worked example: a native + dependency whose `engines` required a newer Node loaded anyway on the older one + and then killed the test worker at the process level, with no JS error and a + summary that still said "passed". + + If your CI pins Node, pin it to 22 as well — running your gates on a runtime + your dependencies no longer support is exactly the split this change closes. + + ## Also updated + + The "Node 18+" prerequisite was restated in ten user-facing places + (`README.md`, `CONTRIBUTING.md`, the getting-started and deployment docs, the + todo example, and the `objectstack-platform` skill's `compatibility` field). + All now say 22. Changelogs and ADRs are historical records and were left alone. + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +### Minor Changes + +- fdb4f50: feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617) + + The ADR-0104 file-as-reference migration ships as a command a deployment runs + against its own database, and the deployment-level flag it records is what may + later authorise irreversible behaviour — never the platform version. + + ```bash + os migrate files-to-references # dry run: reports, writes nothing + os migrate files-to-references --apply # converts, verifies, records the flag + ``` + + The run backfills legacy file-field values (inline metadata blobs, own-resolver + URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership + ledger against what records actually hold, and — only on an `--apply` run whose + reconciliation reports **zero blocking discrepancies** — records + `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. + + **Why a flag rather than a release note.** ObjectStack is a development + platform: third-party deployments upgrade on their own schedule and their data + is not observable by anyone else, so no release-side soak can vouch for them. + The evidence has to be produced where the data is. Consequences: + + - Installing a new version never starts deleting bytes. Running the migration + and passing its self-check is the consent. + - Not run, or not passed → files are retained forever. Wasted storage, zero + data loss. + - A later failing run **clears** `verified_at`: a deployment whose data has + drifted closes its own gate. + - A dry run writes nothing at all — not the conversions, and not the flag, + even when the self-check would pass. + - External URLs stay advisory. They are not `sys_file`s, so they can never + enter collection; whether to remodel them as a `url` field is the app + author's decision (ADR-0104 R7), not a gate. + + Ships alongside: + + - `@objectstack/spec` — `DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`, + and the single `isDataMigrationFlagVerified` predicate both future consumers + (collection #3459, strict value-shape #3438) read, so the two gates cannot + disagree about the same fact. + - `@objectstack/platform-objects` — the `sys_migration` object plus + `readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`. + Reads fail toward "not verified": a gate that cannot read its evidence stays + closed. + - `@objectstack/objectql` — a read may now opt out of file-reference expansion + via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's + bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to + their expanded form before the reconciliation sees them, which reports held + references as absent — noisy `stale_owner` findings, and a missed + `unowned_reference` would have been a false pass of the collection gate. + +- 094fa34: feat(cli,client)!: drop `os environments create --template` and the + `template_id` body field — no control plane has ever read them (#3731) + + The CLI advertised `--template` as _"Built-in template id (e.g. crm, todo, + blank)"_ and forwarded it as `template_id` on `projects.create()`. Nothing + consumes it: `template_id` / `templateId` appears in **zero** non-test files in + the `cloud` repo, `sys_environment` has no such column, and the create route + whitelists what it reads (`displayName`, `organizationId`, `isDefault`, + `hostname`, `metadata`, …) — `template_id` is not in the list. The + `blank`/`crm`/`todo` registry the flag named was the `apps/server` + `createTemplatesRoutePlugin` snapshot, removed when the control plane moved to + `cloud`; the flag outlived it. + + So the flag was accepted, transmitted, and dropped — no seeding, no error, no + stored trace. That is worse than the 404 its listing counterpart returned + (`projects.listTemplates`, deleted in #3702): a 404 tells the caller something + is wrong, a silently ignored flag reports success. + + **Migration.** `os environments create --template ` → drop the flag; it + never did anything. Starter content comes from the App Marketplace: create the + environment, then install the package (`sys_package` rows with + `is_starter = true`, i.e. `client.projects.packages.install(envId, { packageId })`). + Callers passing `template_id` to `client.projects.create()` should delete the + property — TypeScript now rejects it, which is the point: an unknown field was + being silently discarded on the wire. + + Note this is **not** the same `--template` as `os init` / `create-objectstack` + (`app` / `plugin` / `empty` scaffolds) — those are local scaffolding templates + and are untouched. + +- 7ef20d0: feat(cli,automation): catch `label: 'error'` written where `type: 'fault'` was meant (#3863) + + Two of the three items left open on #3863. Both are about making the fault-edge + contract legible; neither changes routing behaviour. + + **New lint — `flow-error-label-not-fault`.** `type: 'fault'` is what routes a + failure; `label` is cosmetic on an ordinary edge. So this, which reads exactly + like error handling: + + ```ts + { source: 'charge_card', target: 'flag_for_review', label: 'error' } + ``` + + is an ordinary out-edge — and `traverseNext` runs every unconditional out-edge + in parallel. The handler fires on every **successful** run of `charge_card`, + concurrently with the real success path, and never on a failure. The run still + aborts when the node fails. + + Silent in both directions: the author believes failures are handled, and never + notices the handler running when nothing went wrong. The reading is especially + natural for an AI author, since the label is precisely what the intent sounds + like — which is why this is worth a build-time diagnostic rather than leaving it + to a puzzled look at a run trace. + + Deliberately narrow, because a label IS load-bearing on a branching node: a + `decision` / `approval` executor returns a `branchLabel` and traversal then + prefers the edge carrying it. Edges out of those node types are excluded, as are + conditional edges (a guarded path is not the unconditional footgun) and edges + already typed `fault`. Matches the obvious synonyms (`error`, `failure`, + `catch`, `on_error`, …) case-insensitively. Verified against the shipped + showcase: no findings. + + An alias — accepting `label: 'error'` as if it were `type: 'fault'` — was + considered and rejected: two spellings for one concept is harder to read than + one spelling plus a diagnostic that names the fix. + + **Pinned: a handled failure does not consume a flow-level retry.** The two + recovery mechanisms have different scopes and must not compound — a `fault` edge + handles one node, while `errorHandling.retry` replays the flow **from the + start**, re-running every node that already succeeded (a second notification, a + second created record). A failure a fault edge handled is not a flow failure, so + it does not consume a retry. That already held by construction (a routed failure + never propagates out of `executeNode`); it is now a test, so a refactor of the + catch path cannot quietly change it. + + Docs and the automation skill gain both points, plus a note on the edge-property + table that `label` does not select a path except on a branching node. + +- 31468fc: feat(cli): `os i18n extract --check` — fail instead of writing when translation bundles have drifted + + Generated translation bundles had no freshness gate, so they rotted silently + until someone happened to re-run the extractor by hand. #3670 found three + distinct drifts sitting in the committed bundles at once: translations left + behind for schema keys that had been REMOVED, keys the schema had GAINED with + no entry at all, and an object whose labels were committed as empty strings + (which renders blank rather than falling back to anything readable). + + `--check` writes nothing and exits non-zero when a fresh extract differs from + what is committed in `--out`, listing each stale or missing file and printing + the exact regenerate command. It runs the identical render path as a real + extract — both branches iterate the same rendered set — so the check can never + disagree with what writing would produce. + + It runs in **merge mode** like any other extract, so it never asks anyone to + re-translate: an up-to-date bundle re-extracts byte-identically. Requires + `--out`, since there is nothing to compare against without it. + + In this repo it is wired up as `pnpm check:i18n` and gated in CI, but the flag + is on the CLI, so any consumer shipping generated bundles can gate them the + same way. + +- a8d1e24: feat(cli,spec): gate the whole declared surface for i18n, and translate inline object actions server-side (#3370) + + In a zh-CN workspace the platform chrome was localized while author-declared + labels leaked English — the approval drawer rendered **Approve / Reject / + Reassign** right beside the inbox's own 通过 / 拒绝. Two independent holes, both + closed here. + + **The lint gate could not see them.** `os lint`'s i18n coverage kept its own + walk of the metadata, separate from the one `os i18n extract` uses to scaffold + bundles, and the two had drifted: coverage only ever walked the _top-level_ + `actions` array, while `sys_approval_request` declares its decision actions + **inline on the object**. Those labels were extractable but ungated, so an + untranslated one could ship and no lint run would notice. Coverage now derives + its expected keys from `collectExpectedEntries()` — the extractor's walker — so + the gated surface and the scaffolded surface cannot disagree again. Newly gated + as a result: inline object actions, action `params` and `resultDialog` copy, + object-nested `listViews` (label / description / `emptyState`), object + `description`, field `help` / `placeholder`, and the `apps` / `dashboards` / + `pages` surfaces. Extract output is byte-identical — verified against the + committed plugin bundles. + + **It stays silent for projects that do not translate.** Which locales get + checked is the project's declaration, never an assumption: `os lint`, + `os i18n check` and `os i18n extract` now read the stack's own + `i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a + bundle already exists for, and finally to `en`. A project with neither is + checked against its default locale alone — which its inline labels already + satisfy — so it reports zero i18n issues. That also fixes a monolingual + _non-English_ project being told it owed `en` translations it never claimed to + speak. Locked by regression tests; the three bundled examples stay at 0 errors. + + **The server sent English regardless of locale.** `translateObject` walked an + object's `label` / `pluralLabel` / `description` / `fields` but never its inline + `actions`, so `GET /api/v1/meta/object/:name` returned the authored English + literals even though `@objectstack/plugin-approvals` ships `_actions` + translations for all eight decision actions in zh-CN / ja-JP / es-ES. The + Console compensated by re-resolving labels client-side against a separately + fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered the + source language. It now runs inline actions through `translateAction`, without + stamping a synthetic `objectName` onto the response. + + Adds `os i18n extract --no-metadata-forms`. Whether the companion + `.metadata-forms.generated.ts` file is written was previously implicit: + every run emitted it, so `--check` demanded that file in packages that + deliberately do not commit one. The Studio metadata-form baseline is + registry-driven and identical for every stack, so exactly one package owns it + (`platform-objects`); a plugin translating only its own objects now opts out, + and its `--check` stops failing on a tree that is in sync. Defaults to emitting, + so `pnpm check:i18n` keeps covering all 8 platform bundles. + +- 2343099: feat(lint): translation-bundle reference integrity + option-key validation (#3583) + + The i18n gate only ever ran forward: `os i18n check` asks which keys the + metadata expects that no bundle carries. Nothing asked the reverse — which keys + a bundle carries that no metadata claims — even though the spec already names + the answer (`TranslationDiffStatus 'redundant'`, `TranslationCoverageResult.redundantKeys`, + both declared with no producer). + + That direction ships two failure modes, both found in the HotCRM audit: bundles + keyed to fields an object no longer declares (a rename that left the translation + behind), and select-option translations keyed by the option's **display label** + or a variant spelling of its value (`direct-mail` for `direct_mail`, `planned` + for `planning`). Neither breaks anything — which is the problem. The resolver + finds nothing and renders the source string, so the screen looks translated and + one field or one picklist value quietly does not. + + New rule `validateTranslationReferences` walks every bundle in + `stack.translations` against the stack it ships with, wired into `os validate`, + `os lint`, and `os compile`: + + | Key | Must name | + | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | + | `objects.{object}` | an object this stack defines, or a platform object | + | `objects.{object}.fields.{field}` | a field that object declares | + | `objects.{object}.fields.{field}.options.{key}` | an option's stored `value` | + | `objects.{object}._views` / `._actions` / `._sections` / `._actions.*.params` | a view `name` / bound action / `fieldGroups[].key` or named section / param `name` | + | `apps.{app}` / `.navigation.{id}` | an app `name` / navigation item `id` | + | `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | dashboard `name` / widget `id` / header `actionUrl` | + | `globalActions.{action}` | an action with no `objectName` | + + Every finding is a **warning** (`translation-target-unknown`, + `translation-option-key-unknown`): an orphan key is inert, not broken, and the + severity should say so. Diagnostics carry the declared names to choose from, + name the stored value when a key turns out to be the display label, and suggest + a namespace-segment match (`task` → `todo_task`) that edit distance alone misses. + + Cross-package objects follow the existing ladder: a registered platform object + is skipped wholly (its fields are not visible from a stack lint), a + platform-prefixed name no package registers is reported once on the object key, + and the subtree is never half-checked. `messages`, `validationMessages`, + `settings`, `settingsCommon` and `metadataForms` are deliberately not judged — + their keys are owned by application code, plugins, and the platform's own + metadata-type registry, so no enumerable universe exists to resolve against. + +- f2b8ac9: Navigation reachability vs. granted access (issue #3583, assessment R5) + + `validate-nav-access` joins what an app's navigation exposes against + `buildAccessMatrix` — the first lint consumer of the ADR-0090 D6 matrix, which + previously only backed `os compile`'s snapshot gate. An object in the menu that + no permission set grants read on renders as an entry and then fails + permission-denied when opened: it works while you browse as an administrator + (the platform's built-in `admin_full_access` carries a wildcard grant) and + breaks for exactly the users the app ships permission sets for. + + Advisory severity — a grant can legitimately come from a permission set another + installed package ships. Quiet by construction in three cases: platform-provided + objects (their own packages grant them), stacks that declare no permission sets + at all (permissions managed elsewhere, so flagging every entry says nothing), + and any stack where a set carries a wildcard `objects: { '*': … }` grant — the + shape `admin_full_access` itself uses, which the access matrix records under the + literal key `*`. + + Wired into `os validate`, `os lint`, and `os compile`. + +- 17749fc: Page-component field bindings and non-dashboard chart bindings (issue #3583, Phase 2) + + Two more reference-integrity rules from the #3583 assessment, both wired into + `os validate`, `os lint`, and `os compile`. + + **`validate-page-field-bindings`** — `PageComponent.properties` is an untyped + bag, so a highlights strip, KPI card, or details section can name a field the + bound object does not have; the component silently skips it. Which object a + component binds follows `dataSource.object` → `properties.object` → the page's + `object`, so multi-object pages are checked per element. `record:related_list` + resolves its columns/sort/filter against the **related** object and its + add-picker against that picker's own object. Advisory (matching + `FORM_FIELD_UNKNOWN`). Relationship paths, system fields, cross-package objects, + and unregistered component types are skipped. + + **`validate-chart-bindings`** — extends ADR-0021 axis checking past dashboards to + report charts (`report.chart` and `report.blocks[].chart`), list-view charts + (`views[].list`, `views[].listViews.*`, `objects[].listViews.*`), and + dataset-bound page chart components. An axis naming a raw field instead of a + declared measure is an **error** (the series comes back empty); an axis naming a + declared-but-unselected measure is a **warning**. The report shape needed its own + handling: `ReportChartSchema` narrows `xAxis`/`yAxis` to bare strings, which the + dashboard rule's array guard skips silently. The react `` block is + object-bound, not dataset-bound, and is deliberately left out — nothing defines + what its aggregate names the result column. + + **Fixes:** the page walk used by `validate-action-name-refs` read a top-level + `page.components` array, which `PageSchema` does not have — components live under + `regions[].components[]` and `slots`, and sub-trees nest inside the untyped + `properties` bag (`children`, `items[].children`, `body`, `footer`) rather than a + `children` key on the component. The rule was therefore visiting nothing on a + schema-parsed stack. Traversal now lives in one shared, tested module; on the + showcase app it reaches 194 components where the previous shape found 46. + Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are skipped — their + `regions` hold a derived cache the `source` wins over. + +- 67452d1: feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589) + + Custom system pages authored as metadata (Installed Apps, Cloud Connection, + Connect an Agent) hard-code their `page:header` copy in + `properties.title` / `properties.subtitle`. Every other metadata type is + localized at the REST boundary, but `page` was not: the `pages` namespace + existed only on `AppTranslationBundleSchema` — a schema no runtime reads — + with no resolver behind it, so those headers stayed English in every locale + while the matching nav labels translated correctly. + + - `TranslationDataSchema` (the shape the i18n service actually serves) gains a + `pages` namespace: `pages..{label,description,title,subtitle}`. + - New `translatePage` in `@objectstack/spec/system` translates a page's own + `label` / `description` and overlays `title` / `subtitle` onto every + `page:header` in the page's regions. Registered in + `translateMetadataDocument`, so it rides the existing read path. + - `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale + extraction, the locale-keyed ETag, and `Vary: Accept-Language` already + covered every metadata type — no new plumbing. + - `objectstack i18n extract` now emits page entries, including the + `page:header` copy, so the new namespace is not invisible to the tooling. + - zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus + the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these + existed only in zh-CN). + + Header copy is keyed by **page name**, not by component id: `page:header` + instances carry no stable id. `title` falls back to `pages..label`, since + a page's header title and its nav label are normally the same string. + + Authoring is unchanged and English literals stay in metadata as the fallback — + a page with no `pages` entry renders exactly as before. Consumers of + `@object-ui` need no change: pages arrive already localized from the server. + +- 4340f13: feat(lint,cli): flag flow `update_record` writes to readonly fields at design time (#3425) + + A flow `update_record` node that writes a field the target object declares + `readonly: true`, under the default `runAs: 'user'` identity, is a **silent + no-op**: the objectql engine strips static-`readonly` fields from a non-system + UPDATE payload (#2948), so the intended write never lands — yet the step still + reports `success`. #3407/#3413 surfaced the strip as a run-time step warning; + this moves the discovery **left** to `os validate` / `os build` so an author + finds the mismatch at design time instead of by reading server WARN logs days + later. + + - New `@objectstack/lint` rule `validateReadonlyFlowWrites(stack)` — a pure + `(stack) => Finding[]` check (ADR-0019). A static `readonly:true` field + written by a literal `update_record` under `runAs !== 'system'` is a + 100%-certain no-op → **error** (gates the build). A `readonlyWhen` field is + per-record-state → **warning** (advisory). Deliberately narrow to stay + false-positive-free: `create_record` (INSERT is engine-exempt from the strip), + `runAs: 'system'` flows (the intended "automation maintains it" channel), + templated object names, and non-literal `fields` maps are all skipped. + - Wired into `os validate` and `os compile`/`os build`, mirroring the existing + security-posture gate (errors fail; advisories print dimmed). + + The formal contract, unchanged in behavior: `readonly` governs the end-user / + API surface (REST/UI and `runAs:'user'` flows strip it); trusted system writers + (`runAs:'system'`, system hooks, seeds) maintain it. To let a flow maintain a + readonly field, declare `runAs: 'system'`. + +- f163028: Reference-integrity validation for object and action names (issue #3583) + + A HotCRM audit found ~20 shipped instances of one bug class — metadata naming + something that does not exist — all passing `objectstack validate` / `lint` + cleanly and failing silently at runtime. This closes the object-name and + action-name half of that class. + + **New — `@objectstack/spec`:** `PLATFORM_PROVIDED_OBJECT_NAMES`, a curated + registry of every object name contributed by a platform package, official + plugin, or the cloud runtime, plus `isPlatformProvidedObjectName()` and + `hasPlatformObjectPrefix()`. This replaces the `startsWith('sys_')` prefix guess + that could not tell `sys_user` (real) from `sys_approval_process` (fictional — + removed by ADR-0019, registered by nothing), which is why every fictional + platform-prefixed reference shipped. A conformance test scans each package's + `*.object.ts` declarations and fails if the registry drifts. + + **New lint rules** (wired into both `os validate` and `os lint`): + + - `validate-object-references` — action-param `reference` / `objectOverride`, + dashboard `globalFilters[].optionsFrom.object`, and navigation + `requiresObject` gates. Severity follows resolvability: an unresolved + _unprefixed_ name is a typo (**error** — `object: 'user'` where the platform + object is `sys_user`); an unresolved _platform-prefixed_ name is **advisory**, + since a third-party package may still provide it. + - `validate-action-name-refs` — the surfaces that bind an action BY NAME: + list-view `bulkActions` / `rowActions`, page `record:quick_actions` + `actionNames`, and nav action items. A name matching no defined action is an + **error** (the button renders and does nothing), matching the existing + dashboard-action-target rule. + + **Fixes:** + + - `defineStack` cross-reference validation now walks `app.areas[].navigation` — + an areas-based app previously got no navigation checking at all — and recurses + into `children` on `object` nav items, not only `group` ones. + - `os lint` i18n coverage now reads field `options` in the canonical + `{value,label}[]` array shape; it only handled the record map, so option-label + coverage silently never fired for canonically-shaped select fields. + - Hook `condition` expressions are now field-checked when `object` is an ARRAY + of targets (previously only a single string target was checked, so a + multi-target hook filtering on a nonexistent field passed clean). Per-target + diagnostics are de-duplicated. + - A dashboard widget binding no `dataset` at all is now reported instead of + silently bypassing every binding and chart check on the raw-config + (`lint`/`doctor`) paths. `dataset` is schema-required, so this matches what + the parsed paths already enforce. + +- 5cfd4d5: feat(cli): the serve storage fallback declares the default datasource instead of constructing a driver (#3826) + + The last open-core second site of "definition → live driver": when a host + `objectstack.config.ts` supplies objects but no driver plugin, `serve` built a + driver via `createStorageDriver` and registered it through `DriverPlugin`, with + its connect and failure verdict landing in `ObjectQLEngine.init()` — the same + split #3869 removed from the standalone stack. + + - **`createStorageDriver` is gone.** `resolveStorageDefinition` translates the + driver kind + URL into `{ driverId, config }` (a pure host-side translation, + like `standalone-stack`'s), and serve hands it to the runtime's + `DefaultDatasourcePlugin` — same shared factory, same `bootCritical` failure + verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` escape hatch, and the primary + DB's real status in Setup → Datasources. + - **`mysql`/`mysql2` joined the shared driver factory** (SqlDriver over + `mysql2`; DSN or discrete fields, secret as password). + - **Host-composition passthroughs**: the factory honours `config.autoMigrate` + (the #2186 dev loosen-only self-heal, for the SQL kinds) and `config.persist` + (the CLI's wasm `on-disconnect` mode). Connection builders ignore both keys. + - **`turso`/libSQL fails loud at resolution**, same typed + `UnsupportedDriverError`, same actionable message — nothing is constructed to + fail later. + - **The `telemetry` sibling datasource stays a pre-built `DriverPlugin`** — the + documented escape hatch for named auxiliary drivers. Its provisioning now + gates on the statically-known sqlite file path; the old coupling to the + primary's _resolved_ engine is replaced by the telemetry provision's own + step-down check, which already guarded the ABI-broken case. + + Verified end to end: a host-composed config (plugins + objects, no driver) + boots through the declared fallback with the same banner labels; the artifact + path (`dev:crm --fresh`) is table-for-table unchanged (71 tables, zero + `no such table`). + + **Migration.** None for CLI users — same URLs, same env vars, same banner. The + removed `createStorageDriver` was CLI-internal; `resolveDriverType`, + `inferDriverTypeFromUrl` and `UnsupportedDriverError` are unchanged. + +### Patch Changes + +- 50616d9: feat(spec,cli): warn the author when a deprecated action alias is discarded (#3743) + + #3742 made `target` beat the deprecated `execute` alias everywhere and had the + `ActionSchema` transform **drop** the alias from its output, so "two different + scripts for one button" became unrepresentable. What it left behind: an author + who declares both slots with different values still loses one of the two + handlers they wrote, **silently**. Per Prime Directive #12 that belongs at + authoring time, so it is now reported there. + + **New rule — `action-target-execute-conflict` (advisory).** An action declaring + both `target` and `execute` with different values gets a warning naming both + handlers, stating that `target` wins, and giving the one-line fix (delete + `execute`). Identical values in both slots are harmless duplication and stay + quiet. It never fails the build: the resulting stack is well-defined — the cost + is a handler that never runs, not a broken artifact. + + The rule must run **pre-parse**, because the parse is what consumes the alias: + once `ObjectStackDefinitionSchema` has run there is no `execute` key left to + report. It therefore lives in `@objectstack/spec` + (`lintDeprecatedAliases`, exported from the package root) and is wired into + both layers that perform the discard: + + - **`defineStack`** — the dominant authoring path, and the one that consumes the + alias earliest: it parses inside your own config module, so by the time + `os build` loads that module the alias is already gone. It now warns on the + console before parsing (once per distinct conflict per process). + - **`os build` / `os validate`** — a new pre-parse pass covering stacks that + skip strict `defineStack`: a plain object default-export, + `defineStack(…, { strict: false })`, and inline function handlers (`target` is + `z.string()`, so those cannot pass strict `defineStack` and are lowered by the + CLI instead). Both commands lint the same input, so they agree by construction + (#3782). + + Each layer reports only its own discards, so one authored conflict produces + exactly one warning however the stack is compiled. + + **Behaviour fix in the same contract.** #3742 fixed compile-time precedence by + probing for a _callable_ `target` first, which left one combination still + resolving the alias's way: a **string** `target` beside a **function** `execute` + bound the alias and then overwrote the canonical ref the author wrote. `target` + now wins in every combination of string/function across the two slots, matching + the `ActionSchema` transform — so the new warning states one precedence rule + that is true everywhere. If you relied on an inline `execute` function winning + over a string `target`, move it into `target`; the warning names the action. + + Authoring is otherwise unchanged: `execute` alone is still accepted, still + lowered into `target`, and still documented. + +- 08b5a3d: fix(action): one precedence for `target` vs the deprecated `execute` — lower the alias, then drop it (#3713) + + `execute` is the deprecated alias of `target`, and three readers resolved "the + author declared both" in **two opposite directions**: + + | Reader | Preferred | + | ------------------------------------- | --------- | + | `ActionSchema` transform (spec) | `target` | + | objectui `ActionRunner.executeScript` | `execute` | + | CLI compile step (`lowerCallables`) | `execute` | + + So `defineAction({ type: 'script', target: 'preferredHandler', execute: 'legacyHandler' })` + ran `preferredHandler` server-side and `legacyHandler` client-side — two + different scripts for one button, silently, with no error anywhere. Low + frequency (it needs an author to set both, which happens mid-migration or by + copy-paste), but the failure mode is "the wrong code ran". + + **`target` now wins everywhere, and the alias is removed from the parsed + output** — the same "canonical wins, alias disappears" shape as + `agent.knowledge.topics` → `sources`. The conflict is now _unrepresentable_ + rather than merely agreed-upon: no renderer can see a second slot to disagree + about. Worth noting the server runtime never read `execute` at all + (`isHeadlessInvokableAction` gates on `target || body`; dispatch probes + `target`/`name`), so authoring `execute` worked _solely_ because it was lowered + at parse time — dropping it costs the server nothing. + + The CLI's inline-handler lowering had the same bug in compile-time form: with a + function in both slots it bundled the `execute` one and then overwrote + `action.target` with that ref, silently discarding the function the author + declared on `target`. It now probes `target` first and drops the alias. + + **Authoring is unchanged** — `execute` is still accepted on input (`ActionInput`), + still lowered to `target`, and still listed in the reference docs. Nothing to + migrate in your app metadata. + + **Consumers of the parsed metadata**, however, must read the canonical slot: + + - FROM: `parsedAction.execute` → TO: `parsedAction.target` + - One-line fix: delete the alias fallback, e.g. `action.execute || action.target` + becomes `action.target`. + + `z.infer` no longer carries `execute`, so any such reader + fails to compile rather than silently reading `undefined`. The objectui + `ActionRunner` counterpart ships separately. + +- f63cd09: fix(spec): `action.undoable` is `live`, not `experimental` — stop warning on a property that works (#3714) + + The liveness ledger marked `action.undoable` `experimental` on a #1992-era note: + _"no runtime reader yet — neither service-automation nor objectui consume the + action's `undoable` flag (objectui has an UndoManager but does not key off this + field)."_ That was true when written. objectui has since wired **two** readers, + both gating real behaviour: + + | Reader | What the flag gates | + | ------------------------------------------- | -------------------------------------------------------------------------- | + | app-shell `useConsoleActionRuntime.tsx:409` | builds the undo operation the success toast's Undo button invokes (`:147`) | + | app-shell `RecordDetailView.tsx:545` | restores the record's prior field values (`:404`) | + + `components` `action/action-button.tsx:113` forwards the flag for exactly this + reason, per its own comment: _"without this the flag is dropped and the handler + never builds the undo operation."_ + + **Why it mattered.** The CLI liveness lint warns on `experimental` as well as + `dead`, so authoring a _working_ property produced a + `liveness-experimental-property` warning — "declared but NOT enforced at + runtime". An author (or an AI) reading the ledger or that warning concludes + `undoable` is aspirational and skips it, losing a shipped feature. Authoring + `undoable: true` is now silent, and the protocol reference no longer claims + setting it "currently has no effect". + + Nothing to migrate: the schema, the parsed shape, and the runtime are unchanged + — only the classification of what they already do. + + This is the _understating_ failure direction, the mirror of the preview-renderer + over-claims corrected in #3685/#3711/#3686. Both directions have the same root + cause, now written into `packages/spec/liveness/README.md`: **a ledger entry is a + claim with a timestamp, and code moves under it in both directions** — entries + are worth re-verifying rather than trusting indefinitely. + +- 7fb436c: Multi-organization operation is an ENTITLEMENT again: the `group` posture no + longer activates without the enterprise runtime (ADR-0105 D12 correction). + + The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the + posture self-activating — it never probed for `@objectstack/organizations`. That + turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 + D2), and made the weaker isolation the free one, which is not a boundary anyone + would draw on purpose. + + The distinction that was missed: **open code is not free activation.** The wall's + implementation has always lived in the open packages — that is equally true of + `isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a + service the enterprise package registers. Cloud ADR-0016's 铁律 + (强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is + safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture + away. + + ## Changes + + - **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`. + Without it the posture resolves to `single` and reports `degraded`. + - **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE + instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped + both the enterprise package load AND the fail-fast, silently degrading to an + unwalled deployment — the exact ADR-0049 class that guard exists to close. A + `group` request without the runtime now refuses to boot unless + `OS_ALLOW_DEGRADED_TENANCY=1`. + - **New seam — the runtime declares what it entitles.** `org-scoping` may expose + `supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`); + the open side honours it and fails closed on anything not listed. Whether + `group` and `isolated` are one commercial tier or two is packaging policy, and + packaging policy belongs to the commercial runtime rather than hard-coded in + open core. Omitting the field entitles every walled posture, so existing + runtimes are unaffected. + - **`organization_id` stamping returns to the enterprise runtime.** The previous + wave moved auto-stamping into the open engine; that removed the closed + package's only load-bearing runtime duty, so a five-line forged `org-scoping` + registration would have produced a fully working multi-org deployment. With + stamping back where it was, a forged registration yields NULL-org rows the wall + hides — a broken deployment, not an unlicensed working one. + + **Write-side VALIDATION stays open and is unchanged**, including the + bulk-insert coverage: rejecting a forged `organization_id` is a security + property, not a packaging one. Only filling an ABSENT value moved back. + + - Default-organization bootstrap returns to `single`-only; every walled posture + keeps its existing owner (ADR-0081 D1). + + ## Note for operators + + `OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now + **refuses to boot** rather than running single-org. This only affects + deployments that adopted `group` between the two waves. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 4c0df37: fix(cli): the startup banner reads a DSN-declared datasource, and stops printing credentials (#3793) + + `OS_DATABASE_URL="postgres://u:p@127.0.0.1:59437/nope" os serve` printed: + + ``` + Driver: SqlDriver(pg) → (unknown) + ``` + + The driver was identified; the address was not. The banner exists so a + developer sees at a glance which database they are on, and it went blank + exactly when the database is unreachable and the address is the one thing worth + reading. + + `describeRegisteredDriver` knew three of the four shapes a driver's + `config.connection` arrives in — a DSN string, `{ filename }` (sqlite), and + discrete `{ host, port, database }` — but not `{ connectionString }`, which is + what `defaultDatasourceDriverFactory` builds for a pg datasource declared with + `config.url` / `config.connectionString`. So _any_ DSN-declared datasource read + `(unknown)`, while the same datasource spelled out in discrete fields read + fine. (`driver.config` keeps the shape its author passed — pinned by a + driver-sql test — so #3791's knex-side `{ connectionString, connectionTimeoutMillis }` + rewrite neither caused nor worsened this.) + + Chasing that turned up two more `(unknown)`s with the same cause — the reader + returned as soon as a driver had a `config` at all, so it could only ever + succeed for a `SqlDriver`: + + - **MongoDB** keeps its DSN in a top-level `config.url`, and `MongoDBDriver` + _does_ have a `config`, so the "mongo/turso expose the URL on the instance" + arm below it was unreachable — every mongo boot banner read `(unknown)`. + - **In-memory** `config` is `{}` when none was passed, which is truthy, so the + `(in-memory)` arm was unreachable too — a preset-wired memory driver + bannered as `com.objectstack.driver.memory → (unknown)`. + + Both are now read by shape rather than by which arm matched first. A related + latent one is fixed alongside: `SqliteWasmDriver` passes knex a dialect _class_ + as `client`, and the label interpolated it — `SqlDriver(${cfg.client})` would + have pasted the class source into the banner. The label only interpolates a + string `client` now, and falls back to the driver's constructor name. + + Both halves of the original bug are now one renderer, + `utils/connection-display.ts`: + + - **`describeDriverConnection(config)`** knows all four `connection` shapes, + plus the `{ uri }` / `{ url }` mongo-family spellings, applies the same field + precedence to a top-level address, and returns `undefined` — not a guess — + for a function-valued `connection` the host builds per pool checkout. + - **`redactConnectionUrl(value)`** drops credentials rather than masking them. + The previous `//user:****@` mask left two holes: a password supplied with no + username (`postgres://:secret@host/db`) did not match its regex and printed + verbatim, and a Turso DSN carries its secret in the query string + (`libsql://….turso.io?authToken=…`), which was never touched. Userinfo _and_ + query are now dropped; non-URL values (`:memory:`, a sqlite path, + `(in-memory)`) pass through untouched, and the function is idempotent. + + Three byte-identical copies of the old mask (`serve` / `start` / `dev`) and a + fourth variant in `schema-migrate` collapse into that one helper. The + `schema-migrate` copy had the same DSN blindness with higher stakes: it feeds + the `Apply N change(s) to …?` confirm, where a `{ connectionString }` config + rendered as a bare `pg` — an operator was asked to approve a destructive + migration against an unnamed database. + + Banner/prompt output changes accordingly — `postgres://admin:hunter2@db:5432/app` + was shown as `postgres://admin:****@db:5432/app` and is now + `postgres://db:5432/app`. Display only; no configuration or API surface moves. + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- 4921a95: fix(cli): finish the `--json` truncation fix — every command, and the second document it was hiding (#3780 follow-up) + + #3780 routed three commands through `emitJson`. The other ~100 emission sites + still wrote machine output with `console.log`, which on a **pipe** is cut off + at one 64 KiB buffer when the command exits right after: Node buffers pipe + writes asynchronously and the exit tears the process down mid-drain. It is + invisible to whoever writes it — stdout to a TTY is synchronous, so every + interactive run looks perfect while every scripted consumer, the only audience + `--json` has, gets invalid JSON. + + The exit does not have to be an explicit `process.exit`. oclif ends failing + commands with `handle()` → `Exit.exit()` → `process.exit()` and flushes + nothing on that path (`flush()` runs only on `execute()`'s success path), so a + plain `this.exit(1)` — or any thrown error — truncates identically. 73 of the + 104 sites had exactly that shape. Even `lint` was only half fixed: `--eval +--json` writes a whole corpus report and was still on `console.log`. + + All 104 sites now go through `emitJson`, `formatOutput`'s `json`/`yaml` + branches through the same drain-aware write (`--format yaml` truncated too), + and an ESLint rule keeps the pattern from growing back one command at a time. + Control flow is untouched — a following `this.exit(1)` stays, it is simply + safe once the buffer has drained. Output bytes are unchanged: roughly half the + sites emitted compact and half indented, and each keeps whichever it had. + + **Draining the write exposed a second defect underneath it.** Because + `this.exit(1)` _throws_, a command whose body sits in one `try` unwinds its + inner "report and stop" into the outer `catch`, which reports again — so + `os validate --json` on a failing config printed **two** JSON documents, which + is neither valid JSON nor valid JSONL. Truncation had been hiding the second + one. Nine commands had this shape; their catch clauses now re-throw the exit + signal (`isExitSignal`) instead of describing it as a failure. + + Measured on `os validate --json` against a config with 900 schema errors, + piped: + + | | bytes | parses | + | -------------------- | -------------------------------------------: | ------- | + | before | 131072 (exactly two buffers, cut mid-string) | no | + | truncation fix alone | 1514711 (two documents) | no | + | both fixes | 1514648 (one document, 900 errors) | **yes** | + + Pinned end to end: a real command, a real pipe, a payload past several + buffers — plus a control case asserting the `console.log` pattern it replaced + genuinely truncates, so the gate cannot quietly stop testing anything. + + Not covered: the ~30 human-facing `console.log` paths, which are unaffected, + and `os serve` / `os dev` logging. This is deliberately not fixed by forcing + stdout into blocking mode process-wide, which would be one line and cover + everything — the same binary runs the dev server, and a blocking write to a + pipe with a slow reader blocks the event loop, trading truncated JSON for a + server that stalls on its own logs. + +- 0bfdf46: fix(spec,cli): conversion deprecation notices reach the author, not just `os validate` (#3855) + + The ADR-0087 D2 conversion layer rewrites an old-shape key to its canonical + spelling at load and emits a structured `ConversionNotice` for each rewrite. The + conversion being silent about _fixing_ the shape is the point — zero consumer + action. Being silent about having **had** to is not: the notice is the one signal + that says _this spelling retires in protocol N, and your metadata stops loading + then_. + + Two of the three surfaces that run the conversion pass discarded every notice: + + | Surface | Before | After | + | ------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- | + | `os validate` | passed a sink, printed them | unchanged | + | `os build` / `os compile` | **passed no sink — notices discarded** | prints them, and includes a `conversions` array in `--json` under the same key `os validate --json` uses | + | `defineStack` | **passed no sink — notices discarded** | warns on the console, once per distinct conversion site | + + This is the #3782 parity class one layer down: not "does this command run the + gate" but "does it listen to what the gate says". Five conversions are live + today (protocol 11 and 15), so an author on any of those shapes was told by one + command and not the other two — and `defineStack` is where that author actually + is, since it runs inside their own config module. + + `defineStack` surfaces notices in **both** strict and non-strict mode: the + conversion happens on the shared `normalizeStackInput` call before the strict + branch, and `strict: false` does not make the old shape any less retiring. + + A new assertion in `validate-build-gate-parity.test.ts` fails if either command + calls `normalizeStackInput` without a sink, so the gap cannot silently reopen. + + No behaviour change for a stack already on canonical shapes: nothing converts, + so nothing warns. + +- 19e3e6e: feat(runtime)!: the standalone `default` datasource is a declaration, connected through the one datasource path (#3826) + + ADR-0062 D1 asked for exactly one "definition → live driver" path. Construction + converged earlier; the _connect + failure verdict_ half did not — the standalone + `default` driver was pre-built and smuggled into the engine as a `driver.*` + kernel service, so "what if it cannot connect" lived in `ObjectQLEngine.init()`, + a second implementation of the policy `DatasourceConnectionService` owns for + every other datasource. #3741 → #3758 showed what two copies cost: a fix to one + missed the other for three months. + + - **`createStandaloneStack` now emits a datasource DEFINITION**, not a driver. + URL→config translation and `mkdir` stay host concerns; the new + **`DefaultDatasourcePlugin`** (exported from `@objectstack/runtime`) connects + the definition at boot through the shared `DatasourceConnectionService` — + same driver factory, same failure verdict, same retained state. It must be + registered before `ObjectQLPlugin` (boot schema-sync needs the driver); + `createStandaloneStack` orders it correctly. + - **`sqlite-wasm` joined the shared driver factory** (`sqlite-wasm` / + `wasm-sqlite` ids) — it was the last bespoke construction site. + - **`bootCritical` on `ConnectableDatasource`**: the host declares a datasource + the platform cannot run without; a boot connect failure is then fatal + regardless of object bindings, sharing `OS_ALLOW_DRIVER_CONNECT_FAILURE` and + the `DEGRADED BOOT` banner with the engine-level guard. A connect policy that + denies a boot-critical datasource fails the boot loudly — the #3828 "denial is + not a failure" boundary was drawn for optional datasources. + - **`connect(record, { asDefault: true })`**: registers the built driver as the + engine's default under its natural name (no `'default'` stamping — routing to + `default` goes through the engine's default-driver fallback, and the natural + name keeps logs/lookups byte-for-byte with the previous boot). + - **`default` is a host-reserved name**: an app bundle declaring a datasource + named `default` is rejected at load (`AppPlugin`), and the runtime-admin + create rejects it too. It would shadow the host's primary datasource and, if + it passed the auto-connect gate, silently divert every unbound object. + - The primary DB now shows a REAL `status` in Setup → Datasources (#3827) — + `ok` when connected, `error` + reason when the operator boots degraded. + - `ObjectQLEngine.init()` is unchanged and keeps its fail-fast: it re-connects + the already-connected default (every open-core driver's `connect()` is + idempotent), which is exactly the boot verification #3741 wants. + - `DriverPlugin` remains the escape hatch for tests and pre-built/proxy drivers + (e.g. the CLI's `telemetry` datasource) — no longer how the standalone + default boots. The CLI serve config-load fallback (`createStorageDriver`, + incl. mysql/turso) still constructs directly; tracked in #3826. + + **Migration.** Boots through `createStandaloneStack` (CLI `serve`/`dev` + artifact path, quickstarts, embedders using the stack factory) change shape but + not behavior: same driver kinds, same URLs, same fail-fast semantics, same + escape hatch. Embedders that composed `DriverPlugin` manually are unaffected. + An app that declared a datasource literally named `default` now fails to load + with a rename instruction — that name never routed correctly to begin with. + +- 5b89711: feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574) + + A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a + zero, indistinguishable from a metric that is legitimately empty, with nothing + in the console or the server log. `service_dashboard.my_open_cases_by_priority` + in the HotCRM template had shipped broken this way since the day it was + written. + + The token had never been part of the contract. Date macros were frozen in + `date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a + single client resolver; `{current_user_id}` had only prose in an `app.zod.ts` + JSDoc and three ad-hoc client implementations that each handled one surface's + filter shape. Nothing could tell an author their token was wrong. + + - **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing + `CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of + `DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` / + `classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The + module documents what the tokens are _not_: presentation scope, never an + access boundary — that is RLS, which uses the unrelated `current_user.id` + expression root. + - **`@objectstack/lint`** — new `validateFilterTokens` (rule + `filter-token-unknown`, severity `error`). It walks `filter` / `filters` / + `runtimeFilter` subtrees across dashboards, objects, views, reports, + datasets, pages and apps, and reports any placeholder that resolves in + neither vocabulary. It scans for filter _keys_ rather than enumerating known + surfaces, so a new surface following the convention is covered the day it + ships — enumerating surfaces is how the dashboard was missed in the first + place. Navigation `recordId` / `params` are deliberately out of scope: they + resolve `AppContextSelector` ids, which are meaningless in a filter. + - **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`. + + It is an error rather than a warning because of who authors this metadata. An + AI reads a query returning `0` as a correct answer and builds on it; its + correction loop is author → validate → fix, so a diagnostic only reaches it if + it can fail the build. The three spellings the suggestion table covers — + `{current_user}`, `{user_id}`, `{organization_id}` — are each correct + _somewhere else_ in the platform, which is exactly why authors reach for them. + + Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token + that resolves nowhere. + +- b0e5a37: fix(lint,cli): a filter reference that cannot resolve fails the build, not the run (#3426, #3810) + + `validateFlowTemplatePaths` reported every `{record.}` miss as **advisory**, + on the reasoning that an unresolved token renders a blank and the run still + completes. Since #3810 that reasoning no longer holds in one position: inside a + CRUD node's `filter`, an unresolved token does not blank a value, it **deletes + the condition** — and a removed condition matches MORE rows, not fewer. Those + nodes now refuse to execute rather than run a widened query. + + So the rule was warning about metadata whose runtime is already decided: `os +validate` printed a yellow line, exited 0, and shipped a flow that cannot run. + Severity now follows the runtime consequence, by position: + + - **`filter` of `get_record` / `update_record` / `delete_record` → `error`.** + These are the three nodes whose filter `resolveNodeFilter` guards. The finding + says what the runtime will do ("the node refuses to run at execution time") + and why the build gates rather than warns (an absent condition _widens_ the + query). `os validate` exits 1. + - **Every other position → `warning`, unchanged.** A message body, an `http` + url, an `update_record` write payload: the token still renders a blank, the + run still completes, and the head object may legitimately come from another + installed package. `create_record` is deliberately excluded from the gating + set — it writes a payload and has no filter to widen. + + Both rules split this way (`flow-template-unknown-field` and + `flow-template-lookup-traversal`), so a typo and a lookup hop are gated wherever + the runtime refuses them. A reference used in both positions on one node is + reported **once, at error severity**. + + **`os validate` now enforces it.** The command filtered this rule's findings for + `severity === 'warning'` and dropped everything else on the floor, so an error + from it would have been invisible. It now gates on errors first — printing rule + id and config path, and emitting them under `errors` in `--json` — mirroring the + `validateReadonlyFlowWrites` step directly below, which makes the same + shift-left split (a certain runtime failure gates; a state-dependent one + advises). + + Verified against the shipped examples: 33 flows across app-todo, app-crm and + app-showcase produce **no new errors**; the four pre-existing lookup-traversal + warnings sit in `script` / `notify` / `subflow` / `parallel` positions and keep + their advisory severity. + + No authoring change is required for a correct filter. A filter that this rule + now fails is one the runtime would have refused anyway — the difference is that + you find out at `os validate` instead of at 3am. + +- 169b58a: fix(#3426): build-time warning for unresolvable flow template paths + guard the formula re-read + + Two follow-ups to #3426 (the formula/lookup `{record.}` template gap that #3445 began closing). + + **Build-time signal (the issue's fallback ask).** `os validate` now flags a + record-change flow node whose `{record.}` template cannot resolve — + turning the previous SILENT blank into an advisory warning. Two cases, via the + new `@objectstack/lint` rule `validateFlowTemplatePaths`: + + - `flow-template-unknown-field` — `{record.}` where `` is neither a + declared field nor a system column (a typo like `{record.full_naem}`). + - `flow-template-lookup-traversal` — `{record..}`, a cross-object + hop the seeded record carries only as a scalar id (still unsupported; tracked + on #3426). + + Deliberately quiet: formula fields, bare lookup ids, numeric indexes into + `multiple` lookups (#1872), `json` sub-paths, and system columns are NOT flagged, + and flows bound to an object this stack does not define are skipped (no schema to + compare against). + + **Hydration re-read guards.** The `trigger-record-change` computed-field re-read + (#3445) is now (a) skipped when the object declares no `formula` field — the only + thing it adds — via the engine's optional `getObjectConfig`, and (b) memoized per + write on the shared HookContext, so N flows on one written record share ONE + re-read instead of N. Any uncertainty falls back to the prior unconditional + re-read (correctness over the optimization). + +- fd7cfde: fix(lint,cli): the flow-template-path rule reaches `os lint` and `os compile`, not just `os validate` (#3583, #3810) + + `validateFlowTemplatePaths` was wired by hand into `os validate` and nowhere + else. That is precisely the drift `REFERENCE_INTEGRITY_RULES` exists to end + (#3583 §5 D5): the same stack, checked by a different rule subset depending on + which command the author happened to run. + + It mattered more after #3861 gave the rule a gating severity. A `{record.}` + token in a CRUD node's `filter` that names an unknown field — or hops through an + un-expanded relation — makes the runtime **refuse the node** (#3810). `os +validate` failed on it; `os lint` and `os compile` did not look, so a CI job + running either one would build and ship a flow that cannot execute. + + **The rule is now a suite member.** It belongs by the suite's own admission + criterion: a `{record.}` token is a name written in metadata, resolved + against the bound object's declared fields. One line in + `REFERENCE_INTEGRITY_RULES` reaches all three commands, and the hand-wiring in + `validate.ts` is deleted rather than duplicated. + + Before landing this, the rule was run against all three stack shapes the suite + is handed — raw `config` (`os lint`), `normalizeStackInput` output, and + schema-parsed `result.data` (`os validate` / `os compile`) — across `app-todo`, + `app-crm` and `app-showcase`. All three agree finding-for-finding, so moving the + call site does not change what is reported. + + Verified end-to-end on `app-showcase`: all three commands pass unchanged on the + real stack (the four pre-existing lookup-traversal warnings still print, still + advisory), and with one filter token corrupted to `{record.idd}` **all three now + exit 1** — where previously only `validate` did. + + **Also fixed, in the same file.** On a clean run, `os validate --json` never + reported the reference-integrity suite's warnings: `refWarnings` was assembled, + printed to the console, and included in the _failure_ payload, but omitted from + the success-path `warnings` array. Adding the rule to the suite would have + silently dropped its warnings from `--json` for JSON consumers, so `refWarnings` + now appears there — which also surfaces the other five rules' warnings that were + being discarded. Same shape of bug as the dropped errors #3861 fixed: computed, + then thrown away. + +- 189854c: chore(spec,cli): enroll `webhook` in the liveness GOVERNED set (#3462) + + Closes the final third of #3462 (umbrella #1878) — `report` and `dashboard` + landed in #3474; `webhook` was deferred for two reasons, both handled here. + + - **Not a registered metadata type.** `webhook` is absent from the metadata-type + registry, so the gate can't resolve it via `getMetadataTypeSchema`. Registering + it would switch on Studio webhook CRUD, `saveMetaItem` overlay acceptance, and + diagnostics sweeping — the wrong move while the authoring surface is still + disconnected (below). Instead the gate resolves it through a small + `SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before the + registry): the gate only needs to **walk** the schema, not register it. + - **The whole authoring surface is dead (#3461).** Nothing materializes an + authored `webhooks:` entry (stack/connector) into a `sys_webhook` dispatcher + row — the runtime reads only admin-authored `sys_webhook` rows. So + `packages/spec/liveness/webhook.json` classifies all 16 authorable props + **dead** and `authentication` **experimental** (HMAC-`secret`-only, its + existing marker). Per-prop notes record which props a future materializer + (#3461 option A) could remap (e.g. `object`→`object_name`, `isActive`→`active`) + vs which have no sink anywhere — doubling as that mapping table. + - **Author-warning wired (`@objectstack/cli`).** Added + `{ type: 'webhook', key: 'webhooks' }` to `TYPE_COLLECTIONS` in + `lint-liveness-properties.ts`, so `os compile` now advises authors that + `webhooks:` is a silent no-op. The required `url` prop carries the single + warning per webhook (one heads-up per artifact, not one per dead prop); + `isActive` is left unmarked (default(true) boolean). + + This is enrollment only — it does **not** decide #3461's build-the-bridge vs + retire-the-surface question. When that lands, the mapped props flip to live (cite + the materializer) or the ledger is removed with the schema. No spec shape/behavior + change (ledger + gate/lint config only). + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- dac6a08: feat(driver-sql)!: make index drift visible to `os migrate plan` — no more silent DDL at boot (#3728) + + The #3696 unique-scope migration converged **in place**: `syncTableIndexes` ran a + `DROP` + `CREATE UNIQUE INDEX` during `initObjects`, in every environment, + leaving one log line behind. `os migrate plan` showed nothing, because + `detectManagedDrift` was column-only — `ManagedDriftOp` had no index dimension at + all. An operator who wanted to review the DDL before it reached their database + had no way to, and a managed schema was being auto-altered in production, which + the #2186 contract explicitly forbids. + + Index drift is now a first-class dimension, reconciled through the same path as + column drift: + + - **`syncTableIndexes` is additive only.** It creates indexes; it never drops or + rewrites one. `dropLegacyGlobalUniques` is gone. + - **New `DriftOp` variants** — `replace_unique_index` (safe: retire the legacy + platform-wide unique in favour of the tenant composite), `create_index` (safe), + `recreate_index` (needs-confirm; destructive when it tightens to `UNIQUE`), and + `drop_index` (destructive). + - **`detectManagedDrift` reports them**, `os migrate plan` renders them (index + ops display as `table [index_name]`), and `os migrate apply` executes them. + Index DDL is portable, so it applies directly on every dialect — no SQLite + table rebuild. + - **`replace_unique_index` creates before it drops**, so uniqueness is never + unenforced mid-migration and a failed create leaves the schema untouched. + - **Declared `indexes[]` drift is covered too**: an index metadata declares but + the database lacks, and one whose definition no longer matches the declaration + (the additive sync skips those by name, so they could never self-heal). + - **Orphan detection is limited to ObjectStack's own generated naming** + (`uniq_…` / `idx_…`, plus the pre-#3696 `__unique` knex + spelling). A hand-rolled operational index is never reported as drift and + `--allow-destructive` will not delete it. + + **Behaviour change.** Boot no longer rewrites the index unconditionally. Dev + (`autoMigrate: 'safe'`, what `os dev` / `os serve` use) still self-heals on + restart, so local workflows are unchanged. Production now **warns** with an + actionable `os migrate` hint and leaves the schema alone — the deployment stays + on the legacy global unique (multi-tenant inserts still collide) until someone + runs `os migrate apply`. That is the deliberate trade: a visible, pre-inspectable + migration instead of an invisible one. + + Also fixed: `managedObjectIndexes` was never cleared when an object dropped its + `indexes[]`, so drift detection kept expecting an index nobody declared. + + `SchemaDiffEntryKind` gains `index_mismatch` and `unmapped_index`. + +- f022c4d: refactor(lint): one entry point for the reference-integrity suite (#3583 D5) + + Six rules that answer the same question — "does this name resolve to anything?" + — were wired by hand into three CLI commands, so landing a rule meant editing + `validate`, `lint` and `compile`, and forgetting one meant the same stack got a + different verdict depending on which command the author ran. + + New public API on `@objectstack/lint`: + + - `validateReferenceIntegrity(stack)` — runs every reference-integrity rule and + returns the concatenated findings. + - `REFERENCE_INTEGRITY_RULES` — the ordered list behind it (`validateObjectReferences`, + `validateActionNameRefs`, `validatePageFieldBindings`, `validateChartBindings`, + `validateNavAccess`, `validateTranslationReferences`). + - `ReferenceIntegrityFinding` / `ReferenceIntegrityRule` / `ReferenceIntegritySeverity` + — one finding type instead of a six-way union. + + Adding a rule to that list reaches `validate`, `lint` and `compile` with no + further wiring. The individual rule exports are unchanged, so nothing that + imports them directly needs to move. + + Behaviour-preserving: identical findings on the three example apps (zero) and + on the HotCRM corpus (24, unchanged per rule). `os doctor` is deliberately not + converted — it runs only `validateWidgetBindings` and is an environment health + check rather than an authoring gate. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- d1557d9: feat(driver-mongodb)!: declare the driver single-tenant and refuse to boot multi-tenant (#3724) + + `MongoDBDriver` implements **no row-level tenant isolation** — it never reads + `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not + stamped with a tenant column. The layer the SQL driver has (`resolveTenantField` + + - `applyTenantScope`) simply does not exist here, while everything above the + driver — object metadata's `tenancy` block, `applySystemFields` injecting + `organization_id`, the engine threading `tenantId` into every driver call — + operates on the assumption that tenant isolation is a platform guarantee. Point + a multi-tenant deployment's datasource at Mongo and every query read, updated + and deleted other tenants' documents, silently. + + Rather than serve unisolated, the driver now fails fast at startup: + + - The **constructor** and `connect()` call `assertSingleTenantPosture()`, which + refuses any tenancy posture other than `single` (`OS_TENANCY_POSTURE=group` / + `isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true`), + resolved through the shared `resolveTenancyPosture()` so the driver can never + disagree with auth / the registry / the CLI about the mode. The check sits in + the constructor because that is the earliest seam — it fails before a host can + hand the driver anywhere — and `connect()` re-checks in case a host flips the + posture in between. (It originally had to live in the constructor because + `ObjectQLEngine.init()` _caught_ a driver's connect rejection and booted + anyway; that is fixed in the same release, #3741, so both seams abort boot.) + - `syncSchema()` / `syncSchemasBatch()` call `assertObjectsNotTenantScoped()` and + refuse objects declaring `tenancy.enabled: true`, naming every offender in one + message. + - `objectstack serve` / `dev` (CLI) now re-throw this error out of the + auto-driver-registration block instead of swallowing it, so boot exits 1 with + the actionable message — the same treatment `UnsupportedDriverError` already + gets. Matched duck-typed by `code`, so the CLI takes no dependency on the + driver package. + + Both throw `MongoDBMultiTenantUnsupportedError` with + `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected + signal, the remedy, and `@objectstack/driver-sql` as the multi-tenant option. + + There is deliberately **no override env var**: an escape hatch would restore + exactly the silent non-isolation this guard removes. Single-tenant deployments — + every currently-working Mongo deployment — are unaffected. + + This is option B of #3724. Implementing real row-level isolation (option A) + remains open; the `unique` index shape stays single-field until then, which is + now correct by construction rather than by omission. + +- 97e9c30: fix(doctor): point the retired `reference_filters` hint at `lookupFilters` (#1878 §3 recheck) + + `os doctor`'s snake→camel rule table advised "Use `referenceFilters` + (camelCase)" — a key REMOVED from `FieldSchema` in #2377/ADR-0049, which the + non-strict schema silently strips. The live successor is `lookupFilters` (read + by the objectui lookup picker). The rule now matches both spellings and names + the right key. + +- 7aea626: fix(cli): include readonly flow-write warnings in `os validate --json` output + + The `readonlyWhen` flow-write advisory (`validateReadonlyFlowWrites`, #3465) was + printed in human mode but omitted from the `--json` summary's `warnings` array, + where every other advisory category is aggregated. `os validate --json` + consumers (CI, editors) therefore never saw those warnings. Added + `...readonlyWriteWarnings` to the summary array so JSON and human output agree. + +- acbf364: feat(spec)!: retire the last three deprecated authorable aliases (#3855) + + Protocol 17 removes the three keys that a schema transform used to fold into a + canonical slot and drop from the parsed output. Every slot now has exactly one + spelling. + + ## Migration + + | Removed | Use instead | Value shape | + | --------------------------- | ------------------------- | -------------------------------------- | + | `action.execute` | `action.target` | unchanged — a handler / flow / URL ref | + | `field.conditionalRequired` | `field.requiredWhen` | unchanged — a CEL predicate | + | `agent.knowledge.topics` | `agent.knowledge.sources` | unchanged — a list of source tags | + + All three are **pure key renames**. Nothing about the value changes, and no + runtime behaviour changes: each alias was already lowered into its canonical key + at parse time and erased before any consumer saw it, so what shrinks is the + authorable surface, not the semantics. + + **Run `os migrate meta --from `.** It rewrites your source + mechanically — these renames are registered as protocol-17 chain steps, so the + tool applies all three (and every earlier step you skipped) in one pass. Manual + alternative: rename the key. That is the entire fix. + + ```diff + - actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }] + + actions: [{ name: 'convert', type: 'script', target: 'convertHandler' }] + + - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } } + + fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } } + + - knowledge: { topics: ['faq', 'policies'], indexes: ['docs'] } + + knowledge: { sources: ['faq', 'policies'], indexes: ['docs'] } + ``` + + ## Why these reject instead of being ignored + + None of the three schemas is `.strict()`, so deleting a key outright makes Zod + **silently strip** it: the metadata would parse clean and the setting would + simply never take effect — a script action bound to nothing, a field that is + never required, an agent recruiting no RAG context. `FieldSchema` already + carries a comment about the last time that happened (`dataQuality` / `cached`, + #3726 / #3733). + + So each removed key is **tombstoned**: it stays declared as `never`, which makes + writing it a `tsc` error at the authoring site _and_ a parse error carrying the + rename. You cannot lose the setting quietly. + + ## Where to find this if you missed it + + The removal is in the machine-readable change manifest (`spec-changes.json`, + ADR-0087 D4) as three protocol-17 conversions. Per-major manifests **compose**, + so jumping several majors at once still yields a single answer rather than N + changelogs to reconcile — the generated upgrade guide and the `spec_changes` MCP + tool are both projections of that record. + + ## Also removed + + `lintDeprecatedAliases` and its rule-id exports (`ACTION_TARGET_EXECUTE_CONFLICT`, + `FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT`, + `AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT`, `DeprecatedAliasFinding`, + `formatDeprecatedAliasFinding`). That pass existed to warn when an author + declared both an alias and its canonical key, because the parse resolved the + conflict silently. With the aliases gone the parse **rejects** instead, which is + strictly louder — the rule has no subject left. If you imported any of these, + delete the import; there is no replacement because the condition it reported can + no longer occur. + + The CLI's inline-handler lowering also stops binding a function on `execute`. It + runs before the parse, so binding it there would have kept the removed alias + quietly working for one authoring style while every other style rejected it. + +- 29ff3c2: feat(lint): warn on replay-unsafe `mode: 'insert'` seed datasets (#3434 follow-up) + + Seeds are replayed — they re-load on every dev-server boot and every package + re-publish, not applied once — so `mode: 'insert'` (the loader's one mode with + no existing-row check) duplicates its table on every restart. That footgun + shipped undetected until #3434 (showcase memberships grew 3 → 6 → 9). + + Adds `validateSeedReplaySafety` to `@objectstack/lint` (a pure `(stack) => Finding[]` + rule, ADR-0019) and wires it into `os validate` / `os lint`. Every `data[]` seed + declared with `mode: 'insert'` now gets an advisory warning that points at the + idempotent modes (`ignore` / `upsert`) and the `externalId` to match on — a + single natural-key field, or a COMPOSITE list of fields for a join / junction + table with no single key (`['team', 'project']`, the support #3434 added). It + catches the mistake at authoring time instead of on the second boot. + +- 95829a0: feat(lint): warn on seed values outside an object's declared state machine (#3433 follow-up) + + #3433 exempts seed writes from the `state_machine` validation rule, so a seeded + status the FSM does not declare is no longer rejected at write time. A field-level + `select` still catches a value outside its `options`, but a `state_machine` on a + free-text field — or a value that is a valid option yet not a declared FSM state — + now sails through silently: the exemption is a deliberate but blind back door. + + `validateSeedStateMachine` (a pure `(stack) => Finding[]` rule, run from + `os validate` / `os lint`, symmetric with the replay-safety rule from #3434) + re-adds that safety net at author time. It flags any seed record whose + `state_machine`-governed field carries a value outside the machine's declared + states — the union of `initialStates`, the transition-map keys, and the transition + targets. Advisory (`warning`): the exemption itself is legitimate, so the fix-it + points at either adding the state to the machine or correcting the typo, not a hard + build failure. New rule id: `seed-value-outside-state-machine`. + +- 9981c1d: Surface seed outcomes in the `os dev` / `os serve` boot banner (#3415). Seeds run inside the boot-quiet stdout window and SeedLoader's logs sit under the default warn level, so a fixture could silently lose most of its rows — the showcase shipped 1 of 5 projects with zero terminal signal. AppPlugin now stashes the per-boot seed counters on the kernel (`seed-summary` service) and the banner prints `Seeds: X inserted · Y updated · Z skipped`, escalating to a yellow `⚠ … N REJECTED` line when records were dropped. +- d60968c: Surface marketplace rehydrate/heal seed outcomes in the `os dev` / `os serve` boot banner (#3430), extending the config-app Seeds line from #3415. + + The seed pipeline's most useful result lines are all `logger.info`, but `os dev` forwards a default `warn` level and the serve boot-quiet window swallows stdout — so "marketplace package rehydrated onto a fresh DB with 0 rows", a fresh-DB self-heal, and row-level seed failures were all invisible unless you queried the database directly. + + The `seed-summary` kernel service is now a per-source list. AppPlugin (config apps) and the marketplace rehydrate/heal path each contribute a labelled entry, and the banner prints one combined line that ignores the log level: + + ``` + Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠ + ``` + + Fresh-DB heals are marked `(healed on fresh db)`; a marketplace package that installed with seed datasets but landed 0 rows, and any run that dropped records, escalate to a yellow `⚠` line instead of passing silently. + +- ce09d4e: fix(cli): `os validate` runs the four authoring lints `os build` runs — "validate clean, build fails" is gone (#3782) + + `os validate` is documented, and used in CI, as the **read-only superset** of the + gates `os build` runs: same checks, no artifact. It wasn't. Four authoring lints + were wired into `compile.ts` only, and **two of them already fail the build**: + + | Lint | Emits `error` | `os build` | `os validate` (before) | + | -------------------------- | ------------- | ---------- | ---------------------- | + | `lintAutonumberFormats` | yes | ✓ | — | + | `lintViewRefs` (#2554) | yes | ✓ | — | + | `lintFlowPatterns` (#1874) | not yet | ✓ | — | + | `lintLivenessProperties` | no | ✓ | — | + + So an autonumber format naming a field that doesn't exist, or a form action + target naming a LIST view, passed `os validate` cleanly and then failed + `os build`. Reproduced verbatim on `main` against `examples/app-todo`: + `os validate` → "✓ Validation passed"; `os build` → "✗ Autonumber format + validation failed". Worst for the CI setups that gate on `validate` and only + discover the break at deploy time. + + The drift was invisible for a structural reason worth naming: every _other_ gate + on both commands is a shared `@objectstack/lint` import, while these four are + CLI-local `../utils/lint-*` modules that only `compile.ts` ever imported. Nothing + made adding a gate to the build also add it to validate. + + **The fix is two parts.** `validate.ts` now runs all four, mirroring + `compile.ts`'s per-lint severity handling (`error` → exit 1, everything else → + advisory, and into the `warnings` array under `--json`). And a new source-level + test asserts that every `lintFoo(`/`validateFoo(` call site in `compile.ts` also + appears in `validate.ts`, failing with the list of missing gates. That test is + the actual fix for the class of bug — the wiring is just today's instance. + + **What you may newly see.** `os validate` now surfaces every rule these lints + carry, including the advisory ones, so existing projects can see new warnings. + Only `autonumber-*` and view-reference `error` findings change the exit code — + and any project they now fail was already failing `os build`. + + `FlowLintFinding` also gains an optional `severity`, honoured by both surfaces. + No rule sets it today, so flow findings stay advisory; it is the seam that lets + #3760's blocking `flow-runas-unscoped` gate `os validate` and `os build` + together the moment it lands, with no further wiring. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [134df4f] +- Updated dependencies [fe67e34] +- Updated dependencies [3d3fddf] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [735f850] +- Updated dependencies [14252d3] +- Updated dependencies [d058594] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [7101ca2] +- Updated dependencies [587fc91] +- Updated dependencies [415254c] +- Updated dependencies [1f8390b] +- Updated dependencies [3167e29] +- Updated dependencies [0a6fb1e] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [2ba560a] +- Updated dependencies [2dda6e7] +- Updated dependencies [f92096b] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [d2a8695] +- Updated dependencies [84e7be9] +- Updated dependencies [fb90784] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [d75edb9] +- Updated dependencies [0f8ad09] +- Updated dependencies [9dcc0ae] +- Updated dependencies [984396b] +- Updated dependencies [d0fea33] +- Updated dependencies [8f9689f] +- Updated dependencies [0cdb57a] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [1b717e5] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [6ba3788] +- Updated dependencies [8607a55] +- Updated dependencies [b96c11b] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [680e8e8] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [3ea7271] +- Updated dependencies [f243727] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [cf5e033] +- Updated dependencies [094fa34] +- Updated dependencies [5e55739] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [ce1f100] +- Updated dependencies [2fa4ca1] +- Updated dependencies [2f47489] +- Updated dependencies [7ef20d0] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [2c19383] +- Updated dependencies [c88eeda] +- Updated dependencies [de9af8a] +- Updated dependencies [5524f84] +- Updated dependencies [b0e5a37] +- Updated dependencies [169b58a] +- Updated dependencies [fd7cfde] +- Updated dependencies [9bf4588] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [307e0fe] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [5602211] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [aff9e56] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [ef5e72d] +- Updated dependencies [dac6a08] +- Updated dependencies [313d7be] +- Updated dependencies [5faeac6] +- Updated dependencies [394b7a1] +- Updated dependencies [7f4a8a1] +- Updated dependencies [f022c4d] +- Updated dependencies [2343099] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [1c8bf4f] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [d1557d9] +- Updated dependencies [f2b8ac9] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [fc5f126] +- Updated dependencies [adabaa8] +- Updated dependencies [030125b] +- Updated dependencies [605c23f] +- Updated dependencies [4e9e184] +- Updated dependencies [17749fc] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [9bf4588] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [52281b0] +- Updated dependencies [db48ad5] +- Updated dependencies [4340f13] +- Updated dependencies [8e08bc3] +- Updated dependencies [16adb3c] +- Updated dependencies [6f55c63] +- Updated dependencies [1dc94f0] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [a137bbc] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [bbd902d] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [29ff3c2] +- Updated dependencies [abceb0d] +- Updated dependencies [95829a0] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [5cfd4d5] +- Updated dependencies [bd68f08] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [647ec8b] +- Updated dependencies [7457a09] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [a629074] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [e889386] +- Updated dependencies [69f1dfd] +- Updated dependencies [c95ac80] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/rest@17.0.0-rc.0 + - @objectstack/driver-sql@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/service-storage@17.0.0-rc.0 + - @objectstack/client@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/plugin-auth@17.0.0-rc.0 + - @objectstack/lint@17.0.0-rc.0 + - @objectstack/plugin-security@17.0.0-rc.0 + - @objectstack/plugin-approvals@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/mcp@17.0.0-rc.0 + - @objectstack/plugin-hono-server@17.0.0-rc.0 + - @objectstack/service-analytics@17.0.0-rc.0 + - @objectstack/verify@17.0.0-rc.0 + - @objectstack/service-automation@17.0.0-rc.0 + - @objectstack/trigger-record-change@17.0.0-rc.0 + - @objectstack/plugin-pinyin-search@17.0.0-rc.0 + - @objectstack/console@17.0.0-rc.0 + - @objectstack/service-datasource@17.0.0-rc.0 + - @objectstack/plugin-audit@17.0.0-rc.0 + - @objectstack/plugin-reports@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/plugin-sharing@17.0.0-rc.0 + - @objectstack/plugin-webhooks@17.0.0-rc.0 + - @objectstack/service-job@17.0.0-rc.0 + - @objectstack/cloud-connection@17.0.0-rc.0 + - @objectstack/driver-mongodb@17.0.0-rc.0 + - @objectstack/metadata@17.0.0-rc.0 + - @objectstack/service-settings@17.0.0-rc.0 + - @objectstack/account@17.0.0-rc.0 + - @objectstack/setup@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + - @objectstack/driver-memory@17.0.0-rc.0 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.0 + - @objectstack/plugin-email@17.0.0-rc.0 + - @objectstack/service-cache@17.0.0-rc.0 + - @objectstack/service-messaging@17.0.0-rc.0 + - @objectstack/service-package@17.0.0-rc.0 + - @objectstack/service-queue@17.0.0-rc.0 + - @objectstack/service-realtime@17.0.0-rc.0 + - @objectstack/service-sms@17.0.0-rc.0 + - @objectstack/trigger-api@17.0.0-rc.0 + - @objectstack/trigger-schedule@17.0.0-rc.0 + ## 16.1.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index cb8572dc2a..a9e7b45eb9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "16.1.0", + "version": "17.0.0-rc.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 c386400f45..505701a5cc 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,150 @@ # @objectstack/client-react +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [0bab8bb] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [984396b] +- Updated dependencies [8f9689f] +- Updated dependencies [0cdb57a] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [1b717e5] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [094fa34] +- Updated dependencies [5e55739] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [a137bbc] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [48d5a1c] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/client@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index a29e609ef5..367486bf24 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "16.1.0", + "version": "17.0.0-rc.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 a718b96a65..6659f94011 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,1058 @@ # @objectstack/client +## 17.0.0-rc.0 + +### Major Changes + +- 8b9d71e: feat(client,spec)!: the SDK's `ai` namespace now expresses the AI surface that exists (#3718) + + `client.ai` and the AI service were **disjoint sets**. The namespace held three + methods — `nlq`, `suggest`, `insights` — whose URLs no repo has ever mounted + (removed in v17), while `service-ai` mounted 12 routes the SDK could not reach + at all. v17 closed the first half by deleting the dead methods. This closes the + second: the SDK now reaches every route that is meant to be tenant API surface. + + | SDK | Route | + | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | + | `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false`, so the JSON mode is what you get | + | `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames | + | `ai.complete(request)` | `POST /api/v1/ai/complete` | + | `ai.models()` | `GET /api/v1/ai/models` — the ADR-0028 plan-filtered picker list | + | `ai.conversations.create/list/get/update/delete/addMessage` | the six `/api/v1/ai/conversations` routes | + + `ai.chatStream` returns a promise for an async iterable rather than being an + async generator, so the request is issued — and an HTTP error thrown — when you + call it, not when you first iterate. + + **Where the server is.** `service-ai` is a Cloud/EE package in the `cloud` + repo; this repo only proxies `/api/v1/ai/**` and 404s `AI service is not +configured` without it. Check `discovery.services` before calling, exactly as + for any other plugin-provided namespace. For a React chat UI, `useChat()` + (`@ai-sdk/react`) is still the better client — it speaks the same protocol + `ai.chatStream` parses and owns message state; these methods are for callers + that are not components. + + **Breaking — the spec's dead AI declarations are retired.** All three had no + implementation anywhere and no runtime consumer: + + - `Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]` → replaced by the wire + shapes of the real routes: `AiChat{Request,Response}`, `AiStreamChunk`, + `AiCompleteRequest`, `AiModelsResponse`, `AiConversation`, `AiMessage`, + `{Create,List,Update}AiConversation*`. The six retired JSON Schemas are + dropped from `json-schema.manifest.json` (deliberate retirement, #2978). + - `DEFAULT_AI_ROUTES` → deleted, and `getDefaultRouteRegistrations()` returns 8 + groups instead of 9. It declared the three phantom endpoints and had no + runtime consumer; re-declaring the real ones here would recreate the same + illusion, since they are mounted from another repo. + - `AiProtocol` (`aiNlq?` / `aiSuggest?` / `aiInsights?`) → deleted. Nothing + implemented it and nothing dispatched through it. The real server contract is + `IAIService` + `IAIConversationService` in `@objectstack/spec/contracts`. + + **The guard.** `/api/v1/ai/` becomes a bounded prefix exemption in the capstone + (#3642) alongside the control plane — bounded from both ends: only `ai.*` may + use it, and the namespace must still be reaching it. That is not a + wave-through. The reachability check lives where the routes are: + `cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` reads + the table `buildAIRoutes()` returns and drives this SDK against it, so an + `ai.*` URL that stops resolving fails a test in the repo that mounts it. The + wildcard-only bound stays **0** — these URLs never touch the `* /ai/**` row, + which is what certified three dead methods for years. + + The four replaced client tests are worth naming: they mocked `fetch` and + asserted the URL the client _built_, never that anything answered it, and + passed for years against endpoints that did not exist. The new ones assert only + what this repo can honestly know — verb, path, and the body decisions the SDK + makes for you (`stream: false` on `chat`, the 204 on `delete`, SSE frame + parsing) — and leave "does it resolve" to the ledger next to the routes. + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- a137bbc: feat(client)!: remove the `ai` namespace — three methods, none of which ever worked (#3718) + + `client.ai` held exactly three methods, and **no server in any repo has ever + mounted the URLs they build**: + + | Removed | Built | Why it 404ed | + | -------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `client.ai.nlq` | `POST /api/v1/ai/nlq` | declared in `DEFAULT_AI_ROUTES`, which has no runtime consumer — only the spec's own test reads it; `aiNlq?` is an optional protocol method nothing implements | + | `client.ai.suggest` | `POST /api/v1/ai/suggest` | same | + | `client.ai.insights` | `POST /api/v1/ai/insights` | same | + + Found by the AI route ledger (#3718, in `cloud`, where `service-ai` lives), + which enumerates the table `buildAIRoutes()` returns and matches the SDK's URLs + against it. The two sets are **disjoint**: the real AI surface is 12 routes — + `chat`, `chat/stream`, `complete`, `models`, `status`, `effective-model` and six + `conversations` routes — and the SDK expressed none of them. + + **Removed, not deprecated.** A typed method that always throws is worse than no + method: it costs a runtime round-trip to discover, where absence is a compile + error. No working code can break, because there was no working behaviour. This + lands in the v17 major `@objectstack/client` is already taking, which is the + right window for a breaking removal rather than a reason to defer one. + + Expressing the real surface is tracked on #3718 as **new** API, not a rename of + what was removed. For chat, `useChat()` (`@ai-sdk/react`) already speaks the + Data Stream Protocol `POST /api/v1/ai/chat` serves. + + Also removed: the `AI_PLANE` exemption added to the capstone hours earlier + (#3727). With no method targeting `/api/v1/ai/`, an exemption there is a hole + with nothing to cover — the wildcard-only bound stays `0` and now reaches 0 + with nothing exempted to get there. + + The four AI tests in `client.test.ts` are **replaced, not deleted**. They were + the exact shape this audit keeps finding behind green suites: mock `fetch`, + assert the URL the client _built_, never assert that anything answered it. They + passed for years against three endpoints that did not exist. The replacement + asserts the one thing worth defending — the namespace is gone and must not + return without a route behind it. + + `Ai{Nlq,Suggest,Insights}{Request,Response}` are still re-exported straight + from `@objectstack/spec/api`, so anyone holding those types keeps them. + Retiring the spec-side declarations is a separate change. + + Docs corrected: `client-sdk.mdx` carried three copy-pasteable examples that + 404ed, and `plugin-endpoints.mdx` had the AI surface **inverted** — it tabled + the three phantom routes and explicitly denied `/ai/chat`, which is mounted. It + now lists the 12 real ones. + +### Minor Changes + +- 6fdc5c6: feat(client,spec): `ai.agents.*` and `ai.pendingActions.*` — the AI routes the SDK could not reach (#3718) + + #3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted, + then expressed the surface that does exist. It expressed **one** builder's worth + of it. `service-ai` mounts seven; the audit that widened its ledger + (objectstack-ai/cloud#903) counted **ten** routes the SDK cannot reach, nine of + which had simply never been counted. + + This closes the six with the strongest evidence: `objectui` already ships + product on them, over URLs it builds by hand because there was nothing to call. + + **`ai.agents`** — `/ai/chat` talks to the environment's default agent; these + talk to one you name. + + - `agents.list()` — the agents this CALLER may chat with. The route filters by + the caller's permissions (ADR-0049), so an empty list is a legitimate answer + for a seat-less user, not an error to retry. + - `agents.chat(name, request)` / `agents.chatStream(name, request)` — one route, + two methods, mirroring `ai.chat` / `ai.chatStream` rather than inventing a + third shape for the same endpoint. `chat` forces `stream: false` for the same + reason `ai.chat` does: the route streams by default, so leaving the flag to + the caller means the JSON path is the one you have to remember. + + **`ai.pendingActions`** — the human-in-the-loop approval queue. When a tool call + needs a human decision the turn parks an action instead of executing it, and an + app embedding the chat has to render and resolve that queue. + + - `pendingActions.list(options?)` — `status`, `conversationId` and `limit` only. + `AIService.listPendingActions` also accepts `objectName`, but the route never + forwards it; typing it here would offer a filter that silently does nothing. + - `pendingActions.get(id)` + - `pendingActions.approve(id)` — approves **and executes**. Check the returned + `status`: a tool that fails after approval comes back + `{ status: 'failed', error }` with HTTP 200, because the approval succeeded + even though the execution did not. Code that reads only `res.ok` reports a + failed write as a success. + - `pendingActions.reject(id, reason?)` — executes nothing. + + Reads and decisions are separately permissioned server-side (`ai:read` vs + `ai:approve`), so a caller that can list the queue may still be refused on + approve. Handle the 403; one does not imply the other. + + **Typed from what the routes return**, not from what a client might like them + to — the failure #3718 exists to punish. The pending-action shape is the + persisted row, `snake_case` on the wire because that is what it is. Agent rows + require `capabilities`, because that object is what tells a UI which + affordances to render. + + The capstone (#3642) exempts `/api/v1/ai/` by prefix and says the evidence lives + on the other side of the repo boundary. It does: cloud's ledger drives every + `ai.*` method against the tables its builders really return — and since #903 + that means all seven builders, which is what makes these six routes checkable + at all. Their routes come from `buildAgentRoutes()` and + `buildPendingActionRoutes()`, neither of which the ledger could see when the + exemption was written. + +- 0cdb57a: feat(client): `automation.resume()` / `automation.getScreen()` — finish a paused screen flow from the SDK (#3528) + + A `type: 'screen'` flow suspends when it reaches a `screen` node: `execute()` + returns `{ status: 'paused', runId, screen }` and the run waits for input. The + second half of that contract — `POST /automation/:flow/runs/:runId/resume` — + has shipped in the dispatcher since ADR-0019, but the client SDK's automation + surface stopped at `getFlow` / `execute` / `listRuns` / `getRun`. Anything built + on the SDK could therefore _start_ a screen flow and never finish it: the run + stayed suspended and the only way out was hand-rolling the HTTP call. That gap + is what stranded the Console's developer "Flow Runs" test runner, where every + test run of a screen flow orphaned a `paused` row. + + - **`automation.resume(flowName, runId, signal?)`** — posts the collected screen + values as `inputs` (applied as bare flow variables), plus the approval-style + `output` / `branchLabel` the dispatcher already accepts. Returns the next + `{ status: 'paused', screen }` of a multi-step wizard, or the terminal + `AutomationResult`. + - **`automation.getScreen(flowName, runId)`** — the screen a paused run is + waiting on, so a client that did not launch the run (a page reload, another + tab, an inbox) can render the pending step before resuming. + - Both are available on the environment-scoped client + (`client.project(id).automation.*`) as well as the unscoped one. + + Also covers the two dispatcher routes with tests — the resume and screen paths + had none, including the ordering guard that keeps `/runs/:runId/screen` from + being swallowed by the `/runs/:runId` run lookup. + +- d3f2ff6: feat(client): `actions` surface — the SDK path to server-registered actions (#3563 PR-2) + + `client.actions.invoke(object, action, { recordId, params })` and + `client.actions.invokeGlobal(action, opts)` dispatch handlers registered via + `engine.registerAction` (`POST /api/v1/actions/...`). This closes the largest + gap in the #3563 route audit: the whole `/actions` domain — the documented way + to expose custom server-side operations — was unreachable from the SDK, and + every console hand-rolled `fetch` for it. The record id travels in the body, + which both server URL shapes honor; the handler's own business failure comes + back as `{ success: false, error }` rather than a thrown exception. + + The route ledger flips all three `/actions` rows to `sdk` and the gap ratchet + drops 27 → 24. Also takes the documentation-drift findings from the audit: + the client README no longer documents six methods that do not exist, + `CLIENT_SPEC_COMPLIANCE.md` is retired to a tombstone pointing at the + CI-enforced ledger (its "FULLY COMPLIANT" verdict was measured against a + route table nothing consumes), and the docs-site SDK page documents the new + surface. + +- b7550d6: feat(client): `keys`, `shareLinks`, and `security` surfaces (#3563 PR-3) + + Three more domains the route audit found with zero SDK expression: + + - `client.keys.create({ name?, expiresAt? })` — mints a `sys_api_key` + (`POST /api/v1/keys`). The raw secret comes back exactly once; `user_id` + is pinned server-side. There was previously no SDK path to create an API + key at all. + - `client.shareLinks.create / list / revoke` — authenticated management of + record share links. Listing is server-constrained to the caller's own + links; the public token-consumption routes stay browser-only by design. + - `client.security.suggestedBindings.list / confirm / dismiss` — the + ADR-0090 admin surface for package audience-binding suggestions. + + The route ledger flips all seven rows to `sdk` and the gap ratchet drops + 24 → 17. + +- 0164f40: feat(client): the final six route-audit gaps — meta drafts/published/FSM + automation descriptors (#3563 PR-5) + + - `meta.getPublished(type, name)` — the published version of a metadata item + (ADR-0033; compound names pass through unencoded, matching `getItem`). + - `meta.listDrafts({ packageId?, type? })` — pending drafts the active-only + lists hide. + - `meta.getLegalNextStates(object, field, from?)` — ADR-0020 FSM + introspection ("from here, where can this record go?"). + - `automation.listActions({ paradigm?, source?, category? })` / + `automation.listConnectors({ type? })` — the ADR-0018/0022 descriptor + registries backing the Studio designer's pickers. + - `automation.getRuntimeStatus()` — per-flow enabled/bound engine state. + + With these, the #3563 gap ratchet reaches **0** (from 27): every dispatcher + route that should be SDK-expressible is, and the conformance guard keeps it + that way. + +- e295ad1: feat(client): the eleven package-lifecycle methods (#3563 PR-4) + + `client.packages` grows from install/enable to the full lifecycle the server + has shipped for three ADR generations: `update` (manifest edit), + `publish`, `publishDrafts` / `discardDrafts` (ADR-0033 whole-app draft + promotion), `listCommits` / `revertCommit` / `rollback` (ADR-0067 commit + timeline), `revert`, `export`, `adoptOrphans`, `duplicate` (ADR-0070 + portability). All eleven routes existed with no SDK expression — Studio + reached them via raw fetch. + + The route ledger flips all eleven rows to `sdk` and the gap ratchet drops + 17 → 6 (from 27 at the start of the audit). + +- 1003125: feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5) + + `client.approvals` gains the full request lifecycle beyond approve/reject: + `recall` (submitter withdraw), `revise` / `resubmit` (ADR-0044 send-back + round-trip), and the thread interactions `remind` / `requestInfo` / `comment`. + New `client.shares` namespace for per-record sharing grants: `list` / `grant` / + `revoke` (204-safe) under `/data/:object/:id/shares`. REST route-ledger + ratchet: 26 → 17. + +- 6e62a93: feat(client): close the sharing-rules (5) + security-explain (2) + search (1) REST gaps (#3587 batch 4/5) + + New `client.shares.rules` sub-namespace for tenant-wide sharing rules + (M10.17): `list` / `save` / `get` / `delete` (204-safe, grants cascade) / + `evaluate` (reconcile). `client.security.explain` speaks the ADR-0090 D6 + access-explanation contract via the POST transport (the GET query form is the + same `ExplainRequestSchema`). Top-level `client.search` covers global + cross-object search (M10.5). REST route-ledger ratchet: 17 → 9. + +- ecda20c: feat(client): close the 8 reports-family REST gaps (#3587 batch 2/5) + + New `client.reports` namespace speaking the plugin-reports REST surface: + `list` / `save` / `get` / `delete` (schedules cascade), `run`, `schedule`, + `listSchedules`, `unschedule`. The two DELETE routes return 204 — the client + methods return `{ deleted: true }` without attempting to parse an empty body. + Fixed path (`/api/v1/reports` is not in `ApiRoutesSchema`), matching the + keys / share-links precedent. REST route-ledger ratchet: 34 → 26. + +- 6e62a93: feat(client): close the final 9 REST gaps — ratchet 9 → 0 (#3587 batch 5/5) + + `data.clone` (enable.clone duplication) and `data.export` (streaming + CSV/JSON/XLSX; returns the raw `Response` — a file stream, not a JSON + envelope). New `email.send` (IEmailService; branch on the returned `status`). + `analytics.queryDataset` speaks the ADR-0021 REST dataset-query dialect. New + `datasources.external.*` federation admin: `listTables` / `draft` / `import` / + `refreshCatalog` / `validate` (ADR-0015 Addendum, 503-degrading). Every REST + route is now either SDK-expressed or carries a reviewed non-sdk disposition — + the #3587 gap ratchet rests at ZERO. + +- fc968af: feat(client): close the 9 metadata-family REST gaps the #3587 ledger carried (#3587) + + New `meta` surface: `getDiagnostics` (spec-validation sweep), `getReferences` + (reverse references), `getBookTree` (ADR-0046 §6 spine resolution), `getAudit` + (ADR-0010 §3.6 protection trail), `publishItem` / `rollbackItem` / `diffItem` + (ADR-0033 per-item draft lifecycle). The two compound-name routes + (`GET|PUT /meta/:type/:section/:name`) turned out to be already expressible — + `getItem`/`saveItem` pass slashes through unencoded — so they are flipped to + `sdk` with URL-pinning tests instead of new methods (the audit note claiming + an encoding barrier was wrong; only `deleteItem` encodes). REST route-ledger + ratchet: 43 → 34. + +- 7c7e246: feat(authz): expose the caller's delegable scope — the read half of the + delegated-administration gate (ADR-0090 D12 / ADR-0105 D8) + + `adminScope` decided writes but could not be READ: `assignablePermissionSets` + lived only inside `delegated-admin-gate.ts`, so a UI offering "place this + person in a unit, with these positions" (the D8 scoped-invitation form) had no + way to narrow its pickers. It would list the whole tree and let the user + discover the boundary by being refused — which turns an authorization gate into + a validator and makes the boundary invisible until it bites. + + `ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as + `GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`: + + - `placeableBusinessUnitIds` — union of the subtrees where the caller may place + people (scopes granting `manageAssignments`); + - `assignablePositions` — positions whose every distributed permission set the + caller may hand out (containment check included); + - `scopes` — the held `adminScope`s with subtrees resolved, for attribution; + - `isTenantAdmin` — unconstrained, with everything enumerated so a consumer + renders ONE uniform picker instead of special-casing. + + Computed by the same helpers the write gate enforces with, so an option this + reports is one `assert()` accepts — a test asserts that agreement directly. It + NARROWS; the gate still decides. + + Strictly self-scoped: no target-user parameter, so it discloses nothing beyond + the authority the caller already holds (unlike `explain`, which has one and + gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with + no delegated authority gets empty lists, and a deployment without + `@objectstack/plugin-security` gets 501. + +- 094fa34: feat(cli,client)!: drop `os environments create --template` and the + `template_id` body field — no control plane has ever read them (#3731) + + The CLI advertised `--template` as _"Built-in template id (e.g. crm, todo, + blank)"_ and forwarded it as `template_id` on `projects.create()`. Nothing + consumes it: `template_id` / `templateId` appears in **zero** non-test files in + the `cloud` repo, `sys_environment` has no such column, and the create route + whitelists what it reads (`displayName`, `organizationId`, `isDefault`, + `hostname`, `metadata`, …) — `template_id` is not in the list. The + `blank`/`crm`/`todo` registry the flag named was the `apps/server` + `createTemplatesRoutePlugin` snapshot, removed when the control plane moved to + `cloud`; the flag outlived it. + + So the flag was accepted, transmitted, and dropped — no seeding, no error, no + stored trace. That is worse than the 404 its listing counterpart returned + (`projects.listTemplates`, deleted in #3702): a 404 tells the caller something + is wrong, a silently ignored flag reports success. + + **Migration.** `os environments create --template ` → drop the flag; it + never did anything. Starter content comes from the App Marketplace: create the + environment, then install the package (`sys_package` rows with + `is_starter = true`, i.e. `client.projects.packages.install(envId, { packageId })`). + Callers passing `template_id` to `client.projects.create()` should delete the + property — TypeScript now rejects it, which is the point: an unknown field was + being silently discarded on the wire. + + Note this is **not** the same `--template` as `os init` / `create-objectstack` + (`app` / `plugin` / `empty` scaffolds) — those are local scaffolding templates + and are untouched. + +- 5e55739: feat(client)!: delete `projects.listTemplates()` — it targeted a route nothing + has ever mounted (#3702, #3655 finding) + + `client.projects.listTemplates()` built `GET /api/v1/cloud/templates`. That + path is mounted by **nothing**: none of the 17 registrars in `cloud`'s + `cloud-artifact-api-plugin.ts` (91 registrations, enumerated by driving them + against a capturing mock `IHttpServer`), and nothing in this repo — the string + occurred exactly once in each repo, at the call itself. Every invocation was a + 404 with a type signature promising a resolved value. + + "Templates" are real as **data** — `sys_package_templates`, the + `is_starter = true` view over `sys_package`, rendered as a console page — but + there has never been an HTTP route that lists them, and no caller in either + repo (nor in `objectui`) used the method. Mounting a route to satisfy a method + nobody calls is the wrong order: the client's declared shape + (`{ id, label, description, category? }`) does not match `sys_package`'s + columns, so picking that mapping is a product decision, not an implementation + detail. The method returns when a route exists to back it. + + Sixth instance of the `the method exists ≠ the method can be called` class this + audit family keeps finding, after `analytics.explain` / `analytics.meta` + (#3584), `meta.getView` (#3611) and `i18n.getTranslations` / `getFieldLabels` + (#3636) — and the first one only a cross-repo guard could see. The framework + capstone (#3642) exempts the `/api/v1/cloud/` prefix wholesale, because this + repo does not serve those routes; `cloud`'s control-plane ledger (#3655) is + where the mounted set and the SDK are both in scope, and it pins the absence. + + Callers who somehow depended on it were already receiving a 404; read starter + packages through the `sys_package` view (`is_starter = true`) instead. + +- c2d9098: feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + + Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already + surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` + #3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did + not — the same strips happened silently on every batched row — and the typed + client warning + CORS mirror were deferred. This closes those out. + + **Bulk passthrough (metadata-protocol).** + + - `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. + - `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. + - `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. + - **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + + **Contract (spec).** `BatchOperationResultSchema` gains an optional per-row + `droppedFields` (covers `updateMany` + `batch`, which alias + `BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional + aggregated `droppedFields`. Both are omit-when-empty, so existing clients are + unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for + batches — one response header cannot express per-row drops, so the per-row body + field is the canonical bulk channel. + + **Typed client warnings (@objectstack/client).** `CreateDataResult` / + `UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body + channel a type instead of an untyped property. + + **CORS (@objectstack/hono, @objectstack/plugin-hono-server).** + `x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` + allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin + browser can read the single-write drop header. The body `droppedFields` remains + the primary, cross-origin-safe surface — this is a convenience mirror. + + **GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, + but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and + `handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There + is no schema generator or mutation resolver to expose a typed payload field on, + so there is nothing to wire until a GraphQL engine lands — at which point the + protocol-layer `droppedFields` is already present and only the GraphQL schema + projection would remain. + +- 88ef03e: fix(spec,client)!: `GetTranslationsRequest` is locale-only — drop the + `namespace` / `keys` filters no server ever read (#3676) + + `GetTranslationsRequestSchema` declared two optional filters, and the endpoint + description promised one of them ("...for the specified locale and optional + namespace"). Neither serving surface read either: the dispatcher domain body + (`runtime/src/domains/i18n.ts`) takes `parts[1]` / `query.locale`, and + service-i18n (`i18n-service-plugin.ts`) takes `req.params.locale`. Both return + the locale's whole bundle. The SDK meanwhile put both on the query string, so a + caller who passed `keys` to shrink the response shrank nothing and got no + indication the filter was inert — Prime Directive #10's declared ≠ enforced, the + same shape #1475 trimmed out of the validation-rule types. + + Trimmed rather than implemented, on three counts: + + - **No consumer.** No call site in this repo or `objectui` passed either field. + The docs (`content/docs/api/client-sdk.mdx`, `skills/objectstack-i18n/SKILL.md`) + already documented `getTranslations(locale)` as a full-bundle snapshot, so the + schema was the outlier, not the docs. The one thing that did exercise them was + a client test asserting the query string got _built_ — it pinned the phantom + rather than any behaviour, since no server read what it asserted was sent. It + is replaced here by its inverse: a regression test that the request carries no + filter query at all. + - **`keys` could not deliver what it advertises.** `II18nService.getTranslations` + (`contracts/i18n-service.ts`) takes only `locale`, so a filter could only be a + post-filter over an already-materialized bundle. `keys` reads as a payload + optimization; a post-filter saves wire bytes but none of the server work, and + widening the contract would break every implementer (`memory-i18n`, + `file-i18n-adapter`) for a capability with no caller. + - **`keys` has no defined meaning against the current bundle shape.** Under the + retired flat `o.`-dotted dialect, `keys: ['o.account.label']` was an obvious + pick. #3778 settled the tree on one nested `TranslationData` shape, where a + flat `string[]` is neither a path set nor a group set, and a filtered response + would have to be rebuilt as a sparse nested tree to stay schema-valid. That is + a design decision, and nothing is waiting on it. + + `namespace` is the one that got _easier_ — it now lands exactly on + `TranslationData`'s top-level groups, which is what its own description already + said ("e.g., objects, apps, messages"). It is still trimmed here: re-adding an + optional request field is additive and non-breaking the day the Studio's + per-module views actually need it, whereas shipping an unexercised filter path + now means dead code with tests to match, and a declared-but-unread field is + precisely the exemplar the next author copies. + + BREAKING: the two schema fields and the `getTranslations(locale, options?)` + second parameter are removed with no deprecation cycle. Nothing worked through + them — a passed filter was silently ignored — so there is no behavior to + protect. Runtime impact is nil (the fields were optional and now strip); TS + callers passing them fail to compile, which is the intended signal. + +- 7ffc3d3: feat(client,spec)!: delete the 21 dead SDK methods and the four ghost route + tables that underwrote them (#3612, #3587 finding) + + Five client surface families built URLs that exist on NO server surface — + not the dispatcher, not `@objectstack/rest`, not the autonomous service + mounts — so every call was a guaranteed 404: + + - `permissions` (check, getObjectPermissions, getEffectivePermissions) + - `realtime` (connect, disconnect, subscribe, unsubscribe, setPresence, + getPresence) — `service-realtime` registers zero HTTP routes and the + dispatcher deliberately never advertises `/realtime` + - `workflow` (getConfig, getState, transition) + - `views` CRUD (list, get, create, update, delete) — no `/ui/views` route + anywhere + - `notifications` device/preference helpers (registerDevice, + unregisterDevice, getPreferences, updatePreferences) — the ADR-0012 + server side was never built + + Each family was underwritten only by an unconsumed spec `DEFAULT_*_ROUTES` + table — the same disease `DEFAULT_DISPATCHER_ROUTES` had (#3586) — so + `DEFAULT_PERMISSION_ROUTES`, `DEFAULT_VIEW_ROUTES`, `DEFAULT_WORKFLOW_ROUTES`, + and `DEFAULT_REALTIME_ROUTES` are deleted with them; + `getDefaultRouteRegistrations()` now returns 9 registrations. + `ApiRouteType` loses its client-only `'views' | 'permissions'` extras. + + Kept: `client.events` (explicitly local in-memory buffer, no HTTP), + `notifications.list/markRead/markAllRead` (dispatcher-served), + `approvals.*` (ADR-0019 — the real approval decision API), and + `meta.getLegalNextStates` (the real FSM read). + + Breaking for anyone calling the removed methods — a repo-wide and + objectui-wide sweep found one consumer (`useClientNotifications`'s dead + device/preference delegates, trimmed in the objectui companion change); + shipped as minor per the launch-window convention (cf. #3562/#3581/#3595). + Re-adding any of these surfaces requires the server route to exist and a + route-ledger row proving it (#3569/#3609 guards). + +### Patch Changes + +- 37b1346: feat(storage): surface the sys_file id on upload-complete — ADR-0104 D3 wave 2 (PR-1) + + `POST /api/v1/storage/upload/complete` now returns the opaque `sys_file` id + (`data.fileId`), and `client.storage.upload()` surfaces it on the returned + `FileMetadata`. Previously the commit response omitted the id — the caller + could not learn which id to persist after committing an upload, so a file + field could never store a reference. + + Additive and non-breaking (new optional `fileId` on `FileMetadataSchema`; the + client falls back to the presigned id when talking to an older server). This is + the enabling foundation for file-as-reference; the storage model itself is + unchanged in this PR. + +- 0bab8bb: fix(client,runtime): analytics.meta/explain now call routes that actually exist (#3584) + + The route audit (#3563) ledgered four dispatcher↔client shape mismatches. + Re-verification showed the two analytics shapes the client spoke — + `GET /analytics/meta/:cube` and `POST /analytics/explain` — were served by + **nothing**: not the dispatcher, not `@objectstack/rest`, not + `service-analytics`. Both methods 404ed against every deployment. + + - `analytics.meta(cube?)` — FROM `GET /analytics/meta/:cube` TO + `GET /analytics/meta[?cube=]`. The cube argument is now optional; when + given, the dispatcher threads it into `AnalyticsService.getMeta(cubeName?)`, + which always supported the filter. Responses now use the dispatcher envelope + (`{ success, data }`). + - `analytics.explain(payload)` — FROM `POST /analytics/explain` TO + `POST /analytics/sql` (the dispatcher's SQL dry-run route, backed by + `generateSql`). Method name unchanged. + + No migration is expected in practice: a method that unconditionally 404ed can + have no working callers (none exist in objectstack or objectui). Anyone who + had hand-rolled fetches against the imaginary shapes should switch to the + routes above. + + The two storage rows from the same audit are deliberately NOT reshaped: the + presigned/chunked protocol the SDK speaks is registered autonomously by + `service-storage` on any http-server and stays canonical; the dispatcher's + bare `POST /storage/upload` / `GET /storage/file/:id` are reclassified in the + route ledger as a `server-only` low-level compat surface. + +- 984396b: test(plugin-auth): enumerate better-auth's route table — the `/auth/**` wildcard becomes 55 exact rows (#3656) + + The widest hole the #3642 capstone measured. That guard reports how many SDK + calls match only a `**` prefix family rather than a resolvable route, and the + answer was 60 of ~196 — with 54 on `* /auth/**`, the largest and most + security-relevant namespace in the client. `auth.me` builds + `/api/v1/auth/get-session`; a prefix claim cannot tell you better-auth still + calls it that, and better-auth is a third-party dependency on its own release + cadence (this repo already chased its 1.7 column drift in #3624 / #3647). + + `plugin-auth` mounts it with a single catch-all, so there are no per-route + registration calls to capture the way tranche 3 captured + `registerStorageRoutes`. The seam is `auth.api`: every better-auth endpoint + carries `.path` and `.options.method`, so a live instance is the route table. + + `auth-route-ledger.ts` reads it, in two halves checked differently on purpose: + + - **55 reviewed rows** — every route the SDK calls, each naming its client + method, checked strictly against the live table. This is the rename detector. + - **129-path mounted-surface inventory** — checked for exact equality both + ways, so a version bump that adds publicly-mounted auth endpoints becomes a + reviewable CI diff. Machine-maintained rather than reviewed prose: demanding + a rationale for all 129 would make every better-auth upgrade a hundred-row + review and the ledger would rot into rubber-stamping. + + Enumeration is config-dependent, so the inventory is pinned at the + configuration enabling every plugin the SDK targets — the maximal surface — + with the participating `OS_*` env vars cleared so a developer's shell cannot + produce a spurious diff. Mutation-checked: renaming a ledgered route fails the + suite naming it. + + The capstone guard now includes this ledger in its union and prefers exact rows + over wildcard families when matching — without that ordering fix every + `/auth/*` URL would still have been absorbed by `* /auth/**` and the new ledger + would have changed nothing. Wildcard-only matches fall **60 → 3**; the ratchet + moves with them. What remains is `* /ai/**`, whose routes `service-ai` builds + at plugin start. + + No runtime change: a ledger, a guard, and the header/audit-doc notes. + +- 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801) + + `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied + `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and + `resumeInternal` validated **machine state only** — the concurrent-resume latch, + the run exists, the flow exists, the suspended node still exists. Nothing asked + _who was calling_. + + Approval nodes suspend and resume through exactly that mechanism. So a resume + carrying `branchLabel: 'approve'` walked the approve edge with **no approver + check, no `sys_approval_action` row and no status mirror** — the + `sys_approval_request` row and the run then disagreed permanently. The only + thing standing between the route and the approvals rules was convention; the + showcase spelled it out in a comment ("decide via the approvals API, never a raw + engine `resume`"), and a comment in an example is not an access control. + + Removing the route was not the fix: it is load-bearing for **screen flows** — + the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node. + The gate therefore keys on **what the run is parked on**: + + - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) — + a pausing node declares who may continue it. `approval` declares `'service'`. + - The engine refuses a `'service'` suspension unless the signal carries + `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the + owning service stamps in-process — a JSON body can never produce one, so the + transport cannot forge it. `ApprovalService` stamps it on the tail of a + decision it has already authorized and recorded. + - The gate follows a **subflow** pause down to the child the signal would + actually reach, so resuming the parent is not a way around it. + - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers + **403**. Nothing is consumed — the request stays pending and the run stays + parked, so the real decision still lands. + + `screen` and `wait` pauses are unchanged, as is every path that already went + through the approvals API. What changes for consumers: + + - **FROM:** finishing an approval with + `client.automation.resume(flow, runId, { branchLabel: 'approve' })` + **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`). + The old call now answers 403 and changes nothing. + - Registering your own pausing node whose continuation belongs to a service + rather than to whoever holds the run id? Declare `resumeAuthority: 'service'` + on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that + service. + + A suspension now records the node type that produced it + (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend + time so a flow republished mid-pause cannot re-type the node out from under the + gate; rows written before this fall back to the flow definition. + +- 1b717e5: test(client): close the route audit's reverse direction — every SDK URL must match a route some surface mounts (#3642) + + The capstone of the #3563 route audit. The dispatcher (#3563), REST (#3587) and + service-mount (#3636) ledgers all run server → client: enumerate what a surface + mounts, demand a reviewed disposition, and for `sdk` rows demand the named + client method exists. None of them asked the reverse question — does the URL + the client _builds_ match anything a server _mounts_? — so a method could name + a real function, carry a green ledger row, and 404 everywhere. + + That shipped four times, found one at a time by hand: `analytics.explain` and + `analytics.meta` (#3584), `meta.getView` (#3611), and `i18n.getTranslations` / + `getFieldLabels` (#3636) — the last pair having carried green `sdk` rows since + tranche 1. + + `client-url-conformance.test.ts` drives every method on a real client with a + recording `fetch` and matches each captured URL against the union of all four + ledgers. A real drive rather than a hand-written "method X targets route Y" + table, because such a table is an assertion _about_ the code that the code can + drift away from — the exact failure being fixed. Mutation-checked: re-injecting + the #3636 dialect bug fails the suite. + + The sweep's own completeness is asserted, since that is what rots silently — a + new method must be driven or declared `NON_HTTP` with a reason; a driven method + emitting zero requests fails (stale placeholder args are how a sweep quietly + stops covering anything); a URL containing `undefined` fails; and the + `__api-endpoint` `(unmatched)` catch-all is excluded from the pattern set so it + cannot match everything and make the suite vacuous. + + 196 of ~219 methods matched. Two bounds are reported rather than papered over: + `/api/v1/cloud/*` (23 `projects.*` methods) belongs to the sibling `cloud` repo + and is exempt by prefix, bounded so no other namespace can use it (#3655); and + 60 of ~196 matched calls rest only on a `**` prefix claim rather than a + resolvable route — 54 of those on `* /auth/**` — a count the guard ratchets so + it can only shrink (#3656). + + No runtime change: this is a guard plus the ledger-header and audit-doc notes + recording what it does and does not cover. + +- 16adb3c: fix(rest,client)!: reconcile the two REST↔client mismatches the #3587 audit + ledgered (#3610, #3611) + + **#3610 — `POST /api/v1/packages` publish-vs-install collision.** The REST + package registrar claimed the bare `POST /packages` for _marketplace publish_ + (`{manifest, metadata}`), while the dispatcher packages domain gives the same + verb+path _install_ semantics — and REST registers first in the production + stack (first-match-wins), so every `client.packages.install` call landed on + the publish handler and 400'd. Marketplace publish moves to + `POST /api/v1/packages/publish` (breaking for direct callers; a repo-wide and + objectui-wide sweep found zero). The dispatcher's `POST /packages/:id/publish` + (ADR-0033 draft publish) is two segments — different shape, no clash. The + dispatcher already writes both stores on install (`protocol.installPackage`) + and fully uninstalls on DELETE (`protocol.deletePackage`), so the remaining + REST GET/GET/DELETE shadows stay — they are compatible. + + **#3611 — UI view dialect split.** `meta.getView` spoke the `?type=` query + dialect that only the dispatcher `/ui` domain understands; the REST surface + mounts only the path form `/ui/view/:object/:type`, so the query form 404'd + wherever REST serves (e.g. project-scoped bases). The client now sends the + path form both surfaces accept; a URL-pinning test keeps it that way. + + REST route ledger updated: the two `mismatch` rows are resolved (packages + publish row is `server-only` publisher tooling; the ui row flips to `sdk`). + The ledger now carries zero mismatches. + +- 3d5f726: feat(rest): route audit tranche 2 — the REST surface gets its own ledger + + conformance guard (#3587, follow-up to #3563) + + The dispatcher tranche closed its 27 gaps and guards them (#3569…#3579), but + `@objectstack/rest` mounts a second, larger surface the client also reaches — + 89 routes, never audited. `rest-route-ledger.ts` now records a reviewed + disposition for every one of them (38 sdk, 43 gap, 3 server-only, 3 public, + 2 mismatch), and the guard is real enumeration on both sources: RouteManager + routes via the `getRoutes()` introspection seam, and the two + RouteManager-bypassing registrars (`package-routes.ts`, + `external-datasource-routes.ts`) via captured mock-server registrations — no + pinned-by-hand list. The client half + (`rest-route-ledger-coverage.test.ts`) verifies every claimed method exists; + a 43-gap ratchet is wired into CI. Every guard direction was negative-tested. + + Notable dispositions the audit surfaced: `POST /api/v1/packages` is a + publish/install shape collision between REST and the dispatcher (REST + registers first and wins) — ledgered `mismatch`; the REST + `GET /ui/view/:object/:type` path dialect is unreachable by the SDK's + query-param dialect — ledgered `mismatch`; `service-storage` / + `service-i18n` mount a third route surface outside `@objectstack/rest`, + explicitly out of scope here and tracked under #3587. + + No behavior change — data + tests only, plus a scope-note refresh in the + runtime ledger pointing at the new REST ledger. + +- f1a8114: fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636) + + Tranche 3 of the #3563 route audit — the last un-audited server surface. The + dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own + package boundary, and two services mount routes outside both: they reach for + the `http-server` service and register straight on `IHttpServer`, so neither + `RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the + SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture: + expressed, working, guarded by nothing. + + **Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and + `i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each + enumerated for real — the registrar runs against a capturing mock + `IHttpServer` and its registration calls _are_ the route set, so a new route + lands with a reviewed disposition or fails CI. The client half is + `packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the + boundary as relative source imports, never a service→client package edge. + + **Two wire-level 404s fixed.** `i18n.getTranslations` sent + `/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent + `/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's + mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts` + contract — mounts only the path form. Neither call could ever be answered. + Both had carried a green `sdk` row in the dispatcher ledger since tranche 1, + because that guard asks whether the client _method_ exists, not whether it + speaks a URL anything mounts. The client now sends the path dialect, the same + resolution #3611 gave `meta.getView`, and a new suite drives the real client + at a real router so a revert cannot pass quietly. + + **One response-shape fix.** service-i18n's success bodies omitted the + `success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK + returned the raw `{ data: … }` wrapper against that provider while returning + the declared unwrapped shape against the dispatcher — one method, two shapes, + decided by which plugin mounted the route. Its three handlers now emit the + `{ success: true, data }` envelope the `i18n` route group declares. `data` did + not move, so direct body readers are unaffected. + + Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the + browser capability URL objectql stamps into file-field payloads, and the two + local-driver loopbacks). The chunked-upload family, flagged for triage, turned + out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero. + + Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites + and mounted by none (#3641); the cross-surface URL conformance guard that would + have caught all of the above mechanically is the capstone (#3642). + +- 48d5a1c: Route ledger + conformance guard for the dispatcher↔client surface (#3563) + + #3528's root-cause class — a route that exists and works while + `@objectstack/client` has no way to express it — now has an inventory and a + ratchet. `route-ledger.ts` records the audited disposition of every dispatcher + route (sdk / gap / server-only / public / dynamic / mismatch); + The guard is split along the package boundary (a runtime→client edge is a + build cycle): runtime's `route-ledger.conformance.test.ts` fails when a + dispatcher domain lands with no ledger entry and ratchets the audited gap + count (27 at PR-1); client's `route-ledger-coverage.test.ts` fails when a + ledger entry claims a client method that doesn't exist. Findings and follow-up slicing live + in `docs/audits/2026-07-dispatcher-client-route-coverage.md`. No runtime + behavior change. + +- 6633337: fix(service-storage): emit the declared success envelope on all eight routes (#3689) + + #3675 moved the **error** bodies of the autonomously-mounted `/api/v1/storage/*` + routes into the declared `{ success: false, error: { code, message } }` + envelope and deliberately stopped there: unlike the errors, the success bodies + were not an additive fix. They were three shapes, none of them carrying the + `success` flag `BaseResponseSchema` declares and + `ObjectStackClient.unwrapResponse` keys on — + + | Route(s) | Was | Now | + | ---------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------- | + | the six upload routes (`/upload/presigned`, `/upload/complete`, `/upload/chunked`, `…/chunk/:i`, `…/complete`, `…/progress`) | `{ data: {…} }` | `{ success: true, data: {…} }` | + | `GET /files/:fileId/url` | `{ url }` | `{ success: true, data: { url } }` | + | `PUT /_local/raw/:token` | `{ ok: true, key }` | `{ success: true, data: { key } }` | + + — while `storage.zod.ts` declared every one of them as + `BaseResponseSchema.extend({ data })`, and `PresignedUrlResponse` and friends + are `z.infer`red from those schemas and published as the SDK's return types. + The declaration said `success: boolean`; the wire said nothing. It broke + nothing only because the storage SDK methods returned `res.json()` raw — + `any`, so TypeScript could not see the gap and nothing relied on the + declaration. That is the posture i18n was in before #3636, right up until + something did rely on it. + + **The payload moved on two routes, and that is the breaking part.** A direct + HTTP caller reading `body.url` from `GET /files/:fileId/url` must now read + `body.data.url`; one reading `body.ok`/`body.key` from the local adapter's + `PUT /_local/raw/:token` loopback must read `body.success`/`body.data.key`. + `ok` is dropped rather than kept beside `success` — it was a second, private + word for the same thing. The six upload routes are additive: callers already + destructure `.data`, and a new sibling key changes nothing. + + Every in-repo consumer was fixed first, so the two repos are not coupled by + merge order: + + - `client.storage.getDownloadUrl()` now reads through `unwrapResponse`, the + SDK's one standard envelope seam — which strips the envelope when present + and returns the body untouched when not, so a client either side of this + server change resolves the same URL. The other storage methods hand back the + whole envelope by design and were already correct. + - The console's two attachment openers (`RecordAttachmentsPanel`, + `ApprovalsInboxPage`) already read `body?.url ?? body?.data?.url`; objectui + gains tests pinning that tolerance as deliberate. + + Two schemas that were missing are now declared — `FileDownloadUrlResponse` and + `RawUploadResponse` — and `getDownloadUrl` joins `StorageApiContracts`, which + it had never been in. That absence is how its shape drifted outside the + envelope unnoticed. The two `_local/raw/:token` routes stay out of the + registry on purpose: they are the local adapter's own presign loopback, + ledgered `server-only` and addressed as an opaque signed URL rather than as an + API. + + `success-envelope.conformance.test.ts` holds the new shape in place the way + `error-envelope.conformance.test.ts` holds the error one: every route is + driven and its body parsed against the **declared schema** it answers to — not + a restatement — the retired shapes are asserted dead, and the module source is + scanned so a new route cannot bypass the `sendOk` helper. As with #3675, the + route ledgers cannot catch this class of drift: they audit which routes exist + and whether the SDK can address them, not what comes back. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/client/package.json b/packages/client/package.json index 0981901b1e..d1edfa1d91 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "16.1.0", + "version": "17.0.0-rc.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 1bd6a57c68..8edb486e07 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,249 @@ # @objectstack/cloud-connection +## 17.0.0-rc.0 + +### Patch Changes + +- 402f534: fix(objectql): bridge late-registered manifest objects into the metadata service + + Marketplace-installed template packages register through the `manifest` + service on `kernel:ready` (install) or later (HTTP install), but the one-shot + SchemaRegistry→metadata bridge runs once during `ObjectQLPlugin.start()` — + so their objects only ever reached the ObjectQL registry. Every + IMetadataService consumer (AI `describe_object`, Studio object lists, + `metadata.listObjects`) missed them; only the seed loader had grown an + engine-side fallback (#3422). + + The manifest service's `register` now bridges the manifest's own objects into + the metadata service after registering them with the engine, resolving the + service at call time and mirroring the startup bridge's contract: + `register('object', name, obj, { notify: false })` (#3112), skip entries it + did not bridge itself, refresh its own copy on same-package re-install (hot + upgrade). Armed only after `start()` has run the one-shot bridge, and never + on project kernels — boot-time behavior is unchanged. `register` now returns + a promise; the marketplace install/rehydrate paths await it so metadata reads + right after an install are deterministic. + +- 1c8bf4f: fix(marketplace): heal missing sample data when rehydrating installed packages onto a new database + + The install ledger (`.objectstack/installed-packages/`) is anchored to the + project directory while the database can be swapped out from under it — + `os dev --fresh`, a deleted `dev.db`, a `--database` switch. Rehydrate + deliberately never re-seeds (existing rows must not be re-upserted over user + edits on every boot), which left a rehydrated marketplace package PERMANENTLY + empty on a new database: app in the switcher, tables created, zero rows — the + "HotCRM installed but every KPI is 0 / Sales Pipeline all-empty" state. + + Rehydrate now runs the bundled seed datasets iff the manifest actually bundles + them, the user never explicitly purged them, the runtime is single-tenant + (multi-tenant seeding stays owned by the per-org replay), and EVERY seeded + object is empty — one surviving row anywhere means the data is still there and + nothing is touched, so the heal is idempotent across restarts and can never + revert user edits. + + Also fixed along the way: a purge now stamps `sampleDataPurged` on the ledger + entry (so healed restarts respect the deliberate empty baseline), and install + marks `withSampleData: true` when the seed run reports all rows _skipped_ + (already present, e.g. a reinstall over live demo data) instead of leaving the + flag false over a seeded database. + +- 810a3a2: fix(runtime,cloud-connection): multi-tenant seed replay covers every source, not just the first (#3453) + + In multi-tenant deployments (enterprise `@objectstack/organizations`) a brand-new org + gets its own private copy of demo data by replaying the kernel's `seed-datasets` list + on the `sys_organization` insert. That list is meant to hold the union of every seed + source — every config-declared app AND every marketplace package — but two framework + traps (the same pair #3444 fixed for seed-summary) shrank it to just the first source: + + - The standard `PluginContext` exposes `getService`/`registerService` but has NO + `.kernel` handle, so `(ctx as any).kernel?.getService('seed-datasets')` always read + `undefined`. Each source then saw "nothing registered" and overwrote the list with + only its own datasets instead of extending it. + - `registerService` throws on a duplicate name, so the second source's re-register was + swallowed by the surrounding try/catch — its datasets (and, for a config app, its + replayer) silently lost. + + Net effect: with two config apps, or a config app plus marketplace packages, a new org + replayed only the first app's seeds. + + The fix mirrors #3444's seed-summary hardening: `seed-datasets` is now a single shared + array, registered once and mutated in place by every source through a new + `mergeSeedDatasets` helper that reads via the context's own resolver first. AppPlugin's + per-org replayer reads that live list at invoke time instead of a captured snapshot, so + it replays the full union — including datasets merged after its closure was built — and + the replayer itself is registered once and reused by later config apps. + + Covered by seam-level unit tests (accumulation across app + marketplace sources; the + replayer reads the live union). True multi-tenant end-to-end coverage requires the + enterprise `@objectstack/organizations` plugin, which lives in the cloud repo. + +- d60968c: Surface marketplace rehydrate/heal seed outcomes in the `os dev` / `os serve` boot banner (#3430), extending the config-app Seeds line from #3415. + + The seed pipeline's most useful result lines are all `logger.info`, but `os dev` forwards a default `warn` level and the serve boot-quiet window swallows stdout — so "marketplace package rehydrated onto a fresh DB with 0 rows", a fresh-DB self-heal, and row-level seed failures were all invisible unless you queried the database directly. + + The `seed-summary` kernel service is now a per-source list. AppPlugin (config apps) and the marketplace rehydrate/heal path each contribute a labelled entry, and the banner prints one combined line that ignores the log level: + + ``` + Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠ + ``` + + Fresh-DB heals are marked `(healed on fresh db)`; a marketplace package that installed with seed datasets but landed 0 rows, and any run that dropped records, escalate to a yellow `⚠` line instead of passing silently. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index c964df8e49..70fad731d4 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "16.1.0", + "version": "17.0.0-rc.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 eb093e1cfe..d5a090c8f4 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/connector-mcp +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 45edcf4f1f..0b4475d7db 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "16.1.0", + "version": "17.0.0-rc.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 167dcc00f2..d74b2f5b3f 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/connector-openapi +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 3b2166e832..52ca123413 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "16.1.0", + "version": "17.0.0-rc.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 f42fc67541..b4e3904ef4 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/connector-rest +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index b00d850642..5490c1dc50 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "16.1.0", + "version": "17.0.0-rc.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 4688105eeb..ff863bb1ff 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/connector-slack +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 6df727924f..b22d62db71 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "16.1.0", + "version": "17.0.0-rc.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 6bc0632d79..26cbeb0acd 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,88 @@ # @objectstack/console +## 17.0.0-rc.0 + +### Minor Changes + +- 8607a55: Console (objectui) refreshed to `1bb77aa24514`. Frontend changes in this range: + + - fix(flow-runner): honor a screen field's `visibleWhen` — render and validation (framework#3528) (#2899) + - fix(i18n): unconditional Chinese in the chatbot confirm card and field inspector (#2884, #2885) (#2900) + - fix(actions): one precedence for `target`/`execute`, and stop mislabeling server-side `body` (#2896) (#2895) + - fix(i18n): close the last three zh-branch gaps (#2871, part 3) (#2898) + - feat(grid): compute all eleven spec column summary aggregations (#2890) + - feat(console): make `delegated_admin` reachable and narrow both role pickers (framework#3697) (#2891) + - fix(app-shell): localize the two DeclaredActionsBar strings that bypassed i18n (#2762 P0-3) (#2894) + - fix(i18n): delete the four `pick({en,zh})` clones (#2871, part 2) (#2893) + - fix(views): the five per-view-type configs speak the spec vocabulary (#2231 phase 3) (#2892) + - feat(grid): gate list row Edit/Delete and bulk delete on the effective operation set (objectstack#3720) (#2889) + - feat(charts): honor `ChartAxis.stepSize`, `ChartConfig.description` and `.height` (framework#3752) (#2888) + - fix(i18n): retire four hand-rolled zh/en branches (#2871, part 1) (#2887) + - feat(charts): ObjectChart honors the spec `ChartConfig` author shape (#2880) (#2883) + - fix(hooks): stop calling translation hooks inside try/catch (#2879) (#2881) + - fix(charts): a fieldless `count` aggregate keyed its value column `undefined` (framework#3701) (#2878) + - fix(i18n): make `en` the complete source of truth for grid import and set-password (#2872 b/c) (#2877) + - fix(auth): localize the ADR-0069 remediation gate and the auth split-panel (#2870) (#2875) + - fix(metadata-admin): drop the SkillPreview "Required Permissions" panel (framework#3686) (#2874) + - feat(console): scoped-invitation placement — invite straight into a unit and positions (framework ADR-0105 D8) (#2868) + - fix(attachments): read the storage service's new error envelope so gated downloads keep their friendly copy (objectstack#3675) (#2869) + - fix(fls): wire real per-caller FLS into import targets and grid columns, drop dead field.permissions shape (objectstack#3661) (#2866) + - fix(page,field): consume the spec's type/label/maxLength keys (framework#1878 §3 recheck) (#2867) + - fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up) (#2865) + - fix(dashboard,charts): send widget query options to the server, order funnel stages by the pipeline (#2864) + - fix(action): honor the spec disabled predicate on every action-rendering surface (#1885 follow-through) (#2863) + + objectui range: `09c6a177bb4a...1bb77aa24514` + +- b96c11b: Console (objectui) refreshed to `2cb8d78e24ad`. Frontend changes in this range: + + - fix(console): dispatch flow actions from every surface + cover the screen-flow round trip (framework#3528) (#2833) + - feat(approvals): typed output pickers, quick-path guard, expression completion (framework#3447, #2829) (#2831) + - fix(console): make a paused screen flow completable, and stop the runner from tearing down its host (framework#3528) (#2830) + - feat(fields): adopt the file-as-reference value shape — ObjectStack ADR-0104 D3 wave 2 (PR-7) (#2828) + - fix(console): resolve a modal action's `target` as a page, not an object (#3530) (#2826) + - feat(approvals): dynamic decision-output fields + expression approver editing (framework#3447 P2) (#2827) + - feat: render the server's effective API operation set (#3391 PR-4) (#2823) + - fix(console): approval timeline attachment chip shows its name and opens (#2820) (#2821) + - fix(i18n): localize FileField upload widget + approvals snapshot field labels (#2819) + - feat(report)!: drop SpecReportColumn/SpecReportGrouping re-exports + retire the legacy ReportViewer chart fallback (#3463) (#2816) + - feat(plugin-grid): "Import as historical data" option in the Import Wizard (framework #3479) (#2815) + - feat(app-shell): toast when a save silently dropped read-only fields (framework #3431/#3455) (#2814) + - fix(app-shell): remove never-firing `record-change` option from the flow trigger picker (#3427) (#2812) + - fix(form): scroll+focus the first errored field on invalid submit (#2793) (#2813) + - feat(approvals): label pending-approver chips with their group (objectui#2807) (#2811) + - feat(approvals): label pending-approver chips with their group (objectui#2807) (#2811) + - fix(approvals): surface the admin override for a stuck request in the inbox (#3424) (#2810) + - feat(studio): first-class notify flow node in the Studio palette + inspector (#2808) + - feat(app-shell): Studio flow start node offers a "Record created or updated" trigger (#3427) (#2809) + - fix: read spec-canonical keys for dashboard header title and field length rules (#2806) + - fix(kanban): surface off-column records in an Uncategorized lane (#2792) (#2804) + - fix(approvals): Approval Center density + amount emphasis (#2762 P2) (#2805) + - fix(i18n): 补齐记录详情审批按钮与弹窗的国际化文案 (#2791) + - fix(approvals): Approval Center triage + drawer readability pass (#2762 P1-2/3/4/5, P2) (#2803) + - feat(app-shell): surface step warnings in the Flow Runs panel (#3407) (#2802) + - feat(studio): surface the enable.searchable toggle in ObjectSettingsPanel (#2800) (#2801) + - feat(app-shell): localize the automations flow designer & inspector (en-US + zh-CN) (#2796) + - feat(form): consume spec-aligned FormView buttons/defaults in ObjectForm (#2790) + - fix(approvals): Approval Center UX pass — badge nowrap, approve confirm, progress bar, localized declared actions (#2762) (#2789) + - feat(app-shell): group/coalesce repeat notifications in the message center (#2765) (#2788) + - fix(app-shell): 首页与消息中心的未国际化文案 (#2787) + - fix(app-shell): give inline `lookup` action params a real record picker (#3405) (#2786) + - fix(app-shell): map raw sys_activity rows in the inbox Activity tab (#2781) (#2782) + - fix(app-shell): i18n the "Switch Object" breadcrumb dropdown label (#2783) + - fix(data-table): keep right-pinned action column header sticky on horizontal scroll (#2785) + - fix(app-shell): keep list-origin back link when switching detail tabs (#2775) + + objectui range: `cf2d56e32a11...2cb8d78e24ad` + +### Patch Changes + +- 6ba3788: Console (objectui) refreshed to `09c6a177bb4a`. Frontend changes in this range: + + - fix(grid): localize import result errors (objectstack#3566) (#2861) + + objectui range: `c6cfdf1288b6...09c6a177bb4a` + ## 16.1.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 38f91978e0..333592af68 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "16.1.0", + "version": "17.0.0-rc.0", "description": "Prebuilt Console SPA pinned to this @objectstack/framework release. 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 abf6957e91..87435a6cf0 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,450 @@ # @objectstack/core +## 17.0.0-rc.0 + +### Minor Changes + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582) + + Filter values travel as JSON, so a time- or user-scoped slice writes a + placeholder instead of code: + + ```ts + filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' } + ``` + + The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`, + `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it + (#3574). What was missing is the half that _substitutes a value_: **nothing on + the server ever did**. A placeholder reached the driver as the literal string + `'{current_year_start}'`, compared as text, and matched nothing. + + That failure is invisible — an empty widget looks exactly like a metric that is + legitimately zero — so apps worked around it by computing dates at module load, + which freezes "this year" into the built artifact and quietly goes stale. + + **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two + server-side seams every filter passes through: + + - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST + queries, related lists, saved-view filters and flow `find_records` all resolve. + It runs before the middleware chain, so only author-supplied filters are + inspected; RLS/sharing filters are injected downstream from concrete values. + - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's + `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s. + This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds + comparands directly, so a dashboard widget never passes through `engine.find()`. + + Behavioural notes: + + - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for + `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's + on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so + there is still exactly one source of truth for the storage convention. + - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned + per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair + can never straddle a boundary. + - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads + `userId`. A request carrying neither now **throws** instead of resolving to + `null` — a null comparand degrades to `IS NULL` on most drivers and would hand + back the rows the filter was written to exclude. + - An unrecognised placeholder **throws**, carrying the near-miss fix + (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` → + `{current_quarter_start}`). This matches what `objectstack build` already + enforces. Consequence, previously implicit and now load-bearing: a filter value + that is _entirely_ `{...}` is always read as a placeholder, so a literal value + of that shape is not expressible — rename the value. + + Also in this change: `notify` no longer sends the six-character string + `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks + `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)` + turned that into a phantom recipient — the emit "succeeded", addressed nobody, + and said nothing. Unresolved recipients are now dropped, and a node with no + recipient left fails naming the offending template and pointing at the start + node's `config.expand` (#3475), which does hydrate the relation. + +- 4cca74c: fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778) + + A translation authored in the product saved successfully and then rendered + nothing. Not a resolver gap — a contract split. The `translation` metadata type + (`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it) + was registered against `AppTranslationBundleSchema`, an object-first shape keyed + on `o.`. Every resolver, `os i18n extract`, `os i18n check`, the objectui + hooks, and all nine shipped bundles read `objects.`. Nothing bridged the + two, so the save path and the read path never met. + + **Why converge instead of bridge.** A converter was the obvious fix and the + wrong one: it would be throwaway code, and it would start producing _working_ + `o.`-shaped rows — closing the migration-free window that exists precisely + because the feature never functioned. The retired shape's real-world footprint + was zero: all three `*.translation.ts` files in the tree (platform-objects, + CRM and todo examples) were already `objects.`-shaped, contradicting the type's + own registered schema. Converging is a registration fix, not a migration. + + **Breaking.** `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema`, and + their types are **deleted** — no deprecation cycle. Nothing worked end-to-end + through them, so there is no functioning consumer to protect, and a + deprecated-but-present schema is exactly the exemplar an AI agent copies into + new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go + with them: zero implementers, so they advertised a capability the runtime never + delivered. + + **The replacement.** `TranslationItemSchema` — one locale of the same + `TranslationData` groups a file bundle uses, plus the `locale` it translates, + with a `defineTranslation()` factory. An item is one entry of a + `TranslationBundle`; that is the whole type. + + Three details are deliberate, all aimed at the failure being silent rather than + loud: + + - **`locale` is required**, not inferred from the item name. The sync skips an + item whose locale it cannot resolve, and a skip is invisible to whoever — or + whatever — authored it. (The name fallback still covers rows written before + this.) + - **Retired keys are rejected, not stripped.** Zod drops undeclared keys + silently, which would reproduce this bug exactly: save succeeds, nothing + renders. A pre-parse guard turns that silence into a 422 naming the group to + use (`'o' … — use 'objects.'`). It runs ahead of the parse so the + retired keys stay out of the schema itself — the generated JSON Schema and the + Studio editor never advertise a shape that cannot work. + - **`ObjectTranslationData.label` is now optional.** Partial translation is the + normal state and every resolver already treats each key as independent. + Requiring it forced authors to restate the source label just to validate, + filling bundles with fake translations that mask real coverage gaps. + + Also in this change: the authored-translation sync warns (naming the row and the + fix) when it meets a row still in the retired shape instead of loading it into + nowhere, and no longer merges publish bookkeeping (`_lockReason`, + `_packageVersion`, …) into the translation layer. `GET +/i18n/labels/:object/:locale`'s fallback now reads the nested + `objects..fields..label` data it is actually given — it scanned for + flat dotted `o..fields.` keys, a third dialect no producer ever + wrote, so it always returned `{}`. + + Migration: author every translation — file or runtime item — under `objects.`. + `o` → `objects`, `app` → `apps`, `nav` → `apps..navigation..label`, + `dashboard` → `dashboards`, `_globalOptions` → + `objects..fields..options`, `_meta.locale` → top-level `locale`, + `_actions.confirmMessage` → `_actions.confirmText`. `reports`, `notifications`, + `errors`, and `namespace` had no runtime consumer and have no replacement. + +### Patch Changes + +- a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839) + + A grouped row whose dimension value is empty now carries `null` for that + dimension no matter which way the aggregate ran. Downstream code can test the + empty bucket with a plain `value == null` again: charts render their own empty + label, drill-through on that bucket builds `field = null` and returns the rows + it should, and a dashboard no longer changes shape when the driver, the + granularity or the reference timezone changes. + + ### What was wrong + + `engine.aggregate` has two implementations of one feature. It pushes the + aggregate down as SQL when the driver advertises every requested granularity and + the reference timezone is UTC; otherwise it fetches rows and buckets them in JS. + The two disagreed about how to spell "empty": + + ``` + --- same dataset, same query, one row with a NULL value --- + pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …] + in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …] + ``` + + The measures were always right — only the key's type and literal differed — + which is why this went unnoticed for so long: every total reconciled. But the + engine picks a path per query, so the same data produced a different bucket key + on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise), + a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of + which bucket in memory unconditionally. + + It was never date-specific either. A plain `groupBy: ['stage']` over a NULL + column diverged the same way. + + Consumers are written against `null` — they check `== null` and supply their own + empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated + every one of them: it rendered a raw English debug string in the UI, and a drill + on the empty bucket compiled to `field = '(null)'` and matched nothing. + + The in-memory path's comment justified the string as staying "consistent with + the client `useReportData` hook". That hook was removed with ADR-0021, and the + literal never appeared in it. + + ### What changed + + - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key + the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A + null instant and an unparseable one still share one bucket, because SQL cannot + tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL). + - The internal composite bucket id is JSON-encoded, so the empty bucket stays + distinct from a row whose value is the literal string `"null"`. + - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The + empty bucket has no calendar span, so a drill on it opens the unscoped + superset instead of an invented bound — unchanged behavior, honest signature. + - The driver output contract in `@objectstack/spec` now states the rule: a row + with no value keys as `null`, never a sentinel. Propagating NULL through the + bucket expression is the whole of it; a driver only breaks it by adding a + `COALESCE`. + + ### Gates + + `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null + instant, because the divergence would have failed it for a reason it was not + about. Its fixture now has one, so the convergence is held in place — including + for out-of-tree drivers that run the check against themselves. + + Two fixes were needed to make that fixture meaningful: + + - The check folded bucket labels through `String(value)`, which turns SQL NULL + into `'null'` — a label a TEXT column can genuinely hold. A driver spelling + "empty" as a string could compare equal to one returning real NULL. The empty + bucket is now keyed out of band. + - Label sets were compared with `JSON.stringify`, which is sensitive to key + insertion order. Row order is not part of this contract and the two paths + naturally differ (SQL sorts its groups; the in-memory path emits first-seen + order), so a driver with entirely correct buckets could be reported as + disagreeing — with an empty diff message, since nothing actually differed. + The comparison is now order-insensitive. + + A new dogfood check covers the non-date half against real drivers: same dataset, + plain and date-bucketed `groupBy`, both paths, one key. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index ed1e15a74c..d565ddb9ee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "16.1.0", + "version": "17.0.0-rc.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 5ba4d492aa..8646908b63 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,118 @@ # create-objectstack +## 17.0.0-rc.0 + +### Major Changes + +- e47b342: feat!: require Node.js 22 — promise the runtime we actually test (#3825) + + Every published package declared `engines.node: ">=18.0.0"`. **Node 18 reached + end-of-life on 2025-04-30 and Node 20 on 2026-04-30**, so the compatibility + promise covered two runtimes nobody patches — and, after #3830 moved CI to Node + 22, two runtimes nothing in this repo verifies. + + That left the promise and the evidence with **no overlap at all**: + + | | Node version | + | ----------------------------------------------------------------------------------------------- | ------------ | + | What CI validates every PR on | **22** | + | What `release.yml` publishes from | **22** | + | What every shipped Docker image runs (`docker/Dockerfile`, `blank` template, self-hosting docs) | **22** | + | What `engines.node` promised users | **>=18** | + + `engines.node` is now `>=22.0.0` across all 50 manifests. This is the honest + floor: it is the only runtime the packages are built, tested and shipped on. + + ## Migration + + **If you are on Node 22 or newer, nothing changes.** Node 24 (Active LTS since + 2025-10-28) and Node 26 both satisfy the new range. + + If you are on Node 18 or 20, upgrade to Node 22+. Both are past end-of-life and + receive no security patches: + + ```bash + nvm install 22 && nvm use 22 + ``` + + npm and pnpm surface an unsatisfied `engines` as an **`EBADENGINE` warning**, not + a hard failure, so an existing install will not break the moment you upgrade — + but the package is no longer tested on that runtime, and the failures are the + kind that do not announce themselves. #3812 is the worked example: a native + dependency whose `engines` required a newer Node loaded anyway on the older one + and then killed the test worker at the process level, with no JS error and a + summary that still said "passed". + + If your CI pins Node, pin it to 22 as well — running your gates on a runtime + your dependencies no longer support is exactly the split this change closes. + + ## Also updated + + The "Node 18+" prerequisite was restated in ten user-facing places + (`README.md`, `CONTRIBUTING.md`, the getting-started and deployment docs, the + todo example, and the `objectstack-platform` skill's `compatibility` field). + All now say 22. Changelogs and ADRs are historical records and were left alone. + +### Patch Changes + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- 4e9e184: chore(deps): OSV security batch — bump tar to ^7.5.21 (GHSA-r292-9mhp-454m) and + js-yaml to ^5.2.2 (GHSA-pm4m-ph32-ghv5) + + Both are declared-range bumps to the patched releases, so downstream installs + resolve the fixed versions from the published manifests, not just this + workspace's lockfile. The same batch clears the remaining transitive advisories + (next 16.2.11 in apps/docs; workspace overrides for brace-expansion, sharp, + react-router, @sveltejs/kit, @hono/node-server) — those live in pnpm-workspace.yaml + and the private docs app, which do not ship. + ## 16.1.0 ## 16.0.0 diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index a825f7bc43..77ec11052e 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "16.1.0", + "version": "17.0.0-rc.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 67c7699d64..5e1435a077 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -16,7 +16,7 @@ export default defineStack({ // refuse this package at the boundary with the exact migration command, // instead of crashing later. Kept in lockstep with releases by // scripts/sync-template-versions.mjs. - engines: { protocol: '^16' }, + engines: { protocol: '^17' }, }, // `automation` backs flow execution and, per ADR-0097, materializes any diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 5b627698d1..4cabeb9cf7 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -11,16 +11,16 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@objectstack/spec": "^16.0.0", - "@objectstack/runtime": "^16.0.0", - "@objectstack/driver-memory": "^16.0.0", - "@objectstack/plugin-hono-server": "^16.0.0", - "@objectstack/connector-rest": "^16.0.0", - "@objectstack/connector-openapi": "^16.0.0", - "@objectstack/connector-mcp": "^16.0.0" + "@objectstack/spec": "^17.0.0", + "@objectstack/runtime": "^17.0.0", + "@objectstack/driver-memory": "^17.0.0", + "@objectstack/plugin-hono-server": "^17.0.0", + "@objectstack/connector-rest": "^17.0.0", + "@objectstack/connector-openapi": "^17.0.0", + "@objectstack/connector-mcp": "^17.0.0" }, "devDependencies": { - "@objectstack/cli": "^16.0.0", + "@objectstack/cli": "^17.0.0", "typescript": "^6.0.0" } } diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index cc52d44e70..700769b491 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/formula +## 17.0.0-rc.0 + +### Minor Changes + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 7227c1fa3c..ebafcbde47 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "16.1.0", + "version": "17.0.0-rc.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 a1f6e9b4d7..1ac2a8d4fb 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,1144 @@ # @objectstack/lint +## 17.0.0-rc.0 + +### Minor Changes + +- 14252d3: feat(approvals): cross-organization approver targeting — a plant document can + require a group-side sign-off (ADR-0105 D9) + + One organization id used to decide three different things at once in + `openNodeRequest`: where the request row lives, where its inbox index rows + live, and **where its approvers are looked up**. The first two are the + request's own organization by definition. The third is not — a group CFO holds + her `cfo` position in the GROUP organization while the purchase order she signs + off lives in the PLANT organization. `expandPositionUsers('cfo', )` + matched nobody, the slot fell back to the dead `position:cfo` literal, and a + group escalation could not be expressed at all. + + An approver may now declare which organization's directory resolves it: + + ```yaml + approvers: + - { type: position, value: plant_manager, group: plant } + - { type: position, value: cfo, organization: $root, group: finance } + behavior: per_group + ``` + + - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two + common intents need **no deployment knowledge** — flow metadata is portable + across environments while organization ids are minted per deployment. A slug + covers what the symbols cannot, notably a **sibling** organization (a + shared-services centre approving payables for every plant). + - Declared **per approver**, so one node can require a plant manager and a + group CFO in parallel. A node-level form cannot express that without + splitting into serial nodes, which changes the semantics. + - **Bounded, not free:** the target must share a `parent_organization_id` root + with the request's organization. The rule reads only the organization tree — + never the submitter — so one flow routes identically for everyone. + + Everything else fails loudly rather than quietly: + + - a non-`group` posture **refuses** the declaration (a `group` → `isolated` + migration must not silently reroute approvals); + - an approver type with no org-scoped directory (`user` / `field` / `manager` / + `team`) refuses it too, and a new `approval-approver-cross-org-unsupported` + lint catches that at author time; + - a targeted approver holding no membership in the request's organization is + dropped with a warning naming them — D2's union wall would otherwise hide the + request from someone already routed to, so the node's existing + `onEmptyApprovers` policy takes over instead of leaving an unopenable task. + + Nothing changes for an approver without `organization`: same resolution, same + queries, no extra reads. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 33f5e23: feat(lint): `validate-ai-surface-affinity` — skill ↔ agent surface affinity is now linted (#3820) + + An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a skill + declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, §3). The + runtime refuses an incompatible binding with a **load error at chat time** — + after parse, validate, and deploy all passed cleanly. The new rule reports that + contradiction statically, and joins `REFERENCE_INTEGRITY_RULES`, so + `objectstack validate`, `lint`, and `compile` all pick it up with no CLI + changes. + + Scope is deliberately narrow (zero false positives by construction): only + bindings where **both** the agent and the skill are declared in the same stack + are checked. `agent.skills[]` names that don't resolve in-stack (kernel skills + are runtime-registered and statically invisible) are skipped — resolving those + namespaces is #3820 D0/D2, decided by ADR-0109 (Proposed). + + The spec side is doc-truth only, no schema shape changes: + + - `stack.agents` is documented as **platform-internal** (ADR-0063 §2 — the + kernel ships exactly two agents; third parties extend via skills), replacing + prose that still described the withdrawn ADR-0040 per-app-copilot model. + - `stack.tools` is documented as declaration-only pending the ADR-0109 tool + authoring model. + - `app.defaultAgent` is re-documented as a surface-binding knob (`'ask'` + implicit / `'build'` for authoring surfaces), not a custom-agent slot. + - `SkillSchema` now states that a per-skill `permissions` field deliberately + does not exist (ADR-0049) — authoring one is silently stripped; access is + gated by `agent.access` / `agent.permissions` and per-tool authz. + +- 259af21: feat(spec,lint): ADR-0109 Phase 1 — platform tool-name registry + advisory `skill.tools[]` reference lint (#3820 R7) + + ADR-0109 (revised) settles the AI tool authoring model: **the default + third-party path needs no tool records at all.** A skill's `tools[]` names + either a platform-registered tool or a tool the runtime materialises from the + app's own declarative actions (`action_`) — the executable, its authz, + and its audit trail stay on the action/flow the app already ships. Tool + records are demoted to an optional AI-presentation refinement layer (Phase 2, + gated on acceptance). + + Phase 1, shipped here: + + - **`PLATFORM_PROVIDED_TOOL_NAMES`** (`@objectstack/spec/system`) — curated + registry of every statically-named tool the cloud AI runtime registers, + grouped by owning package, plus `PLATFORM_TOOL_FAMILY_PREFIXES` for the + materialised `action_` family and `isPlatformProvidedToolName()`. The + `PLATFORM_PROVIDED_OBJECT_NAMES` precedent, applied to tools; conformance + tests live in the owning cloud packages. + - **`validate-ai-tool-references`** (`@objectstack/lint`) — the #3820 R7 + `skill.tools` branch, wildcard-aware, resolving against declared + `stack.tools` ∪ the registry ∪ the materialised action family. Severity + **warning** (ADR-0078 advisory-first ratchet): the registry cannot see + third-party runtime plugins. Joins `REFERENCE_INTEGRITY_RULES`, so + `validate`, `lint`, and `compile` all pick it up. On the HotCRM corpus it + reports exactly the 10 fictional tool references (0 false positives on the + 6 that resolve). + - **`composeStacks` no longer drops `tools`** — the slot joins the + concatenated array fields, so a declared record survives composition. + - `stack.tools` / AI-slot docs updated to the ADR-0109 model. + +- 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508) + + - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a + designer must source each approver row's `value`: `user`/`team`/`department`/`position` + are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` / + `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the + others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`), + `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is + unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`. + - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep + loading and rendering) but is published in `xEnumDeprecated`, so designers stop + offering it — the runtime has no queue resolution and the slot routes to nobody. The + approver `value` xRef now also maps `manager`, so designers can render its + auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries + `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete + `user`) until a real ownership-queue implementation lands. + - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue` + approver is skipped. + - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver + types that are declared but not implemented by the runtime. + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +- b0e5a37: fix(lint,cli): a filter reference that cannot resolve fails the build, not the run (#3426, #3810) + + `validateFlowTemplatePaths` reported every `{record.}` miss as **advisory**, + on the reasoning that an unresolved token renders a blank and the run still + completes. Since #3810 that reasoning no longer holds in one position: inside a + CRUD node's `filter`, an unresolved token does not blank a value, it **deletes + the condition** — and a removed condition matches MORE rows, not fewer. Those + nodes now refuse to execute rather than run a widened query. + + So the rule was warning about metadata whose runtime is already decided: `os +validate` printed a yellow line, exited 0, and shipped a flow that cannot run. + Severity now follows the runtime consequence, by position: + + - **`filter` of `get_record` / `update_record` / `delete_record` → `error`.** + These are the three nodes whose filter `resolveNodeFilter` guards. The finding + says what the runtime will do ("the node refuses to run at execution time") + and why the build gates rather than warns (an absent condition _widens_ the + query). `os validate` exits 1. + - **Every other position → `warning`, unchanged.** A message body, an `http` + url, an `update_record` write payload: the token still renders a blank, the + run still completes, and the head object may legitimately come from another + installed package. `create_record` is deliberately excluded from the gating + set — it writes a payload and has no filter to widen. + + Both rules split this way (`flow-template-unknown-field` and + `flow-template-lookup-traversal`), so a typo and a lookup hop are gated wherever + the runtime refuses them. A reference used in both positions on one node is + reported **once, at error severity**. + + **`os validate` now enforces it.** The command filtered this rule's findings for + `severity === 'warning'` and dropped everything else on the floor, so an error + from it would have been invisible. It now gates on errors first — printing rule + id and config path, and emitting them under `errors` in `--json` — mirroring the + `validateReadonlyFlowWrites` step directly below, which makes the same + shift-left split (a certain runtime failure gates; a state-dependent one + advises). + + Verified against the shipped examples: 33 flows across app-todo, app-crm and + app-showcase produce **no new errors**; the four pre-existing lookup-traversal + warnings sit in `script` / `notify` / `subflow` / `parallel` positions and keep + their advisory severity. + + No authoring change is required for a correct filter. A filter that this rule + now fails is one the runtime would have refused anyway — the difference is that + you find out at `os validate` instead of at 3am. + +- fd7cfde: fix(lint,cli): the flow-template-path rule reaches `os lint` and `os compile`, not just `os validate` (#3583, #3810) + + `validateFlowTemplatePaths` was wired by hand into `os validate` and nowhere + else. That is precisely the drift `REFERENCE_INTEGRITY_RULES` exists to end + (#3583 §5 D5): the same stack, checked by a different rule subset depending on + which command the author happened to run. + + It mattered more after #3861 gave the rule a gating severity. A `{record.}` + token in a CRUD node's `filter` that names an unknown field — or hops through an + un-expanded relation — makes the runtime **refuse the node** (#3810). `os +validate` failed on it; `os lint` and `os compile` did not look, so a CI job + running either one would build and ship a flow that cannot execute. + + **The rule is now a suite member.** It belongs by the suite's own admission + criterion: a `{record.}` token is a name written in metadata, resolved + against the bound object's declared fields. One line in + `REFERENCE_INTEGRITY_RULES` reaches all three commands, and the hand-wiring in + `validate.ts` is deleted rather than duplicated. + + Before landing this, the rule was run against all three stack shapes the suite + is handed — raw `config` (`os lint`), `normalizeStackInput` output, and + schema-parsed `result.data` (`os validate` / `os compile`) — across `app-todo`, + `app-crm` and `app-showcase`. All three agree finding-for-finding, so moving the + call site does not change what is reported. + + Verified end-to-end on `app-showcase`: all three commands pass unchanged on the + real stack (the four pre-existing lookup-traversal warnings still print, still + advisory), and with one filter token corrupted to `{record.idd}` **all three now + exit 1** — where previously only `validate` did. + + **Also fixed, in the same file.** On a clean run, `os validate --json` never + reported the reference-integrity suite's warnings: `refWarnings` was assembled, + printed to the console, and included in the _failure_ payload, but omitted from + the success-path `warnings` array. Adding the rule to the suite would have + silently dropped its warnings from `--json` for JSON consumers, so `refWarnings` + now appears there — which also surfaces the other five rules' warnings that were + being discarded. Same shape of bug as the dropped errors #3861 fixed: computed, + then thrown away. + +- 9bf4588: feat(lint): flag never-firing record trigger tokens at authoring time (#3427) + + New `flow-trigger-unknown-event` rule in `validateFlowTriggerReadiness`: a flow + start node whose `triggerType` is record-lifecycle-shaped + (`record-before|after-`) but names an op the record-change trigger cannot map + — e.g. a typo like `record-after-updated` — binds to the record-change trigger + yet maps to no ObjectQL hook and never fires, with only a runtime warning. The + rule surfaces that never-fire defect at `os validate` time. Warning severity; + bare `record-` shapes (e.g. `record-change`) are out of scope. + +- f022c4d: refactor(lint): one entry point for the reference-integrity suite (#3583 D5) + + Six rules that answer the same question — "does this name resolve to anything?" + — were wired by hand into three CLI commands, so landing a rule meant editing + `validate`, `lint` and `compile`, and forgetting one meant the same stack got a + different verdict depending on which command the author ran. + + New public API on `@objectstack/lint`: + + - `validateReferenceIntegrity(stack)` — runs every reference-integrity rule and + returns the concatenated findings. + - `REFERENCE_INTEGRITY_RULES` — the ordered list behind it (`validateObjectReferences`, + `validateActionNameRefs`, `validatePageFieldBindings`, `validateChartBindings`, + `validateNavAccess`, `validateTranslationReferences`). + - `ReferenceIntegrityFinding` / `ReferenceIntegrityRule` / `ReferenceIntegritySeverity` + — one finding type instead of a six-way union. + + Adding a rule to that list reaches `validate`, `lint` and `compile` with no + further wiring. The individual rule exports are unchanged, so nothing that + imports them directly needs to move. + + Behaviour-preserving: identical findings on the three example apps (zero) and + on the HotCRM corpus (24, unchanged per rule). `os doctor` is deliberately not + converted — it runs only `validateWidgetBindings` and is an environment health + check rather than an authoring gate. + +- 2343099: feat(lint): translation-bundle reference integrity + option-key validation (#3583) + + The i18n gate only ever ran forward: `os i18n check` asks which keys the + metadata expects that no bundle carries. Nothing asked the reverse — which keys + a bundle carries that no metadata claims — even though the spec already names + the answer (`TranslationDiffStatus 'redundant'`, `TranslationCoverageResult.redundantKeys`, + both declared with no producer). + + That direction ships two failure modes, both found in the HotCRM audit: bundles + keyed to fields an object no longer declares (a rename that left the translation + behind), and select-option translations keyed by the option's **display label** + or a variant spelling of its value (`direct-mail` for `direct_mail`, `planned` + for `planning`). Neither breaks anything — which is the problem. The resolver + finds nothing and renders the source string, so the screen looks translated and + one field or one picklist value quietly does not. + + New rule `validateTranslationReferences` walks every bundle in + `stack.translations` against the stack it ships with, wired into `os validate`, + `os lint`, and `os compile`: + + | Key | Must name | + | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | + | `objects.{object}` | an object this stack defines, or a platform object | + | `objects.{object}.fields.{field}` | a field that object declares | + | `objects.{object}.fields.{field}.options.{key}` | an option's stored `value` | + | `objects.{object}._views` / `._actions` / `._sections` / `._actions.*.params` | a view `name` / bound action / `fieldGroups[].key` or named section / param `name` | + | `apps.{app}` / `.navigation.{id}` | an app `name` / navigation item `id` | + | `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | dashboard `name` / widget `id` / header `actionUrl` | + | `globalActions.{action}` | an action with no `objectName` | + + Every finding is a **warning** (`translation-target-unknown`, + `translation-option-key-unknown`): an orphan key is inert, not broken, and the + severity should say so. Diagnostics carry the declared names to choose from, + name the stored value when a key turns out to be the display label, and suggest + a namespace-segment match (`task` → `todo_task`) that edit distance alone misses. + + Cross-package objects follow the existing ladder: a registered platform object + is skipped wholly (its fields are not visible from a stack lint), a + platform-prefixed name no package registers is reported once on the object key, + and the subtree is never half-checked. `messages`, `validationMessages`, + `settings`, `settingsCommon` and `metadataForms` are deliberately not judged — + their keys are owned by application code, plugins, and the platform's own + metadata-type registry, so no enumerable universe exists to resolve against. + +- f2b8ac9: Navigation reachability vs. granted access (issue #3583, assessment R5) + + `validate-nav-access` joins what an app's navigation exposes against + `buildAccessMatrix` — the first lint consumer of the ADR-0090 D6 matrix, which + previously only backed `os compile`'s snapshot gate. An object in the menu that + no permission set grants read on renders as an entry and then fails + permission-denied when opened: it works while you browse as an administrator + (the platform's built-in `admin_full_access` carries a wildcard grant) and + breaks for exactly the users the app ships permission sets for. + + Advisory severity — a grant can legitimately come from a permission set another + installed package ships. Quiet by construction in three cases: platform-provided + objects (their own packages grant them), stacks that declare no permission sets + at all (permissions managed elsewhere, so flagging every entry says nothing), + and any stack where a set carries a wildcard `objects: { '*': … }` grant — the + shape `admin_full_access` itself uses, which the access matrix records under the + literal key `*`. + + Wired into `os validate`, `os lint`, and `os compile`. + +- 2a5f04a: `` aggregate result-column naming is now a contract, and its axis bindings are validated (issue #3701) + + Split out of #3583 Phase 2 (#3684), which extended ADR-0021 axis checking to + report charts, list-view charts, and dataset-bound page chart components but had + to leave the react `` block out: it is OBJECT-bound (`objectName` + + an inline `aggregate`), `aggregate` existed in the contract only as the + description string `'{ field, function, groupBy }'`, and nothing in the repo said + what the aggregated result columns were called. Without that, `xAxis`/`yAxis` had + nothing to resolve against, and guessing a convention would have manufactured + false positives (ADR-0072 D1). + + **The convention, recorded rather than invented.** Every path that can serve an + object-bound chart already agreed — the engine's structured-`groupBy` aggregate + (whose alias objectui sets to `field || function`), the legacy analytics query + (which remaps its measure key back to `field`), the client-side fallback, and the + console's own chart-view wiring (`xAxisKey: groupBy`, `series[].dataKey: field`). + `packages/spec/src/ui/chart-aggregate.ts` writes it down and exports it: + + - an object-bound aggregate returns rows keyed by the **raw field names** — + `groupBy` for the category column, `field` for the value column, the literal + `count` for a fieldless count, plus `__comparison` under a comparison + overlay; + - `chartAggregateCategoryKey` / `chartAggregateValueKey` / `chartAggregateResultKeys` + derive those columns so producers and checkers cannot re-derive them apart; + - `ChartAggregateSchema` replaces the description string with a real Zod schema + and rejects a non-`count` function with no `field` (which used to reach the + renderer as `sum(undefined)` and render blank). + + This is the deliberate opposite of the dataset path, whose rows are keyed by the + declared measure `name` (`sum_amount`) — the trap `chart-measure-unknown` catches. + Only the dataset path has an author-chosen name to key by. + + **``'s contract now names the props it actually reads.** The block + consumes `xAxisKey` and `series[].dataKey`; `ChartConfig`'s `xAxis`/`yAxis`/`series` + shapes reached it and were silently dropped, which ADR-0078 forbids. They are + removed from the block's `dataProps`; `chartType`, `xAxisKey`, and `series` are + declared in the React overlay where the other bindings live. + + **`validate-react-page-props` now reads attribute VALUES**, not just names, for + ``: + + - `react-chart-field-unknown` (error) — `aggregate.field` / `aggregate.groupBy` + naming a field the bound object does not declare; + - `react-chart-aggregate-invalid` (error) — an unimplemented aggregation + function, or a non-`count` function with nothing to aggregate; + - `react-chart-axis-unknown` (error) — `xAxisKey` / `series[].dataKey` naming a + column the aggregate does not return (including a dataset-style `sum_total`), + or a category axis bound to the value column; + - `react-chart-axis-inert` (warning) — the `xAxis` / `yAxis` shapes this block + never reads. + + Value reading is opt-in per block and evaluates only static literals: a prop + driven by React state or a variable, a usage carrying a `{...spread}`, a chart + given inline `data`, and objects another package defines are all skipped + silently — an unresolvable binding is not a wrong one. + +- 4f740b0: ``'s author contract is the spec `ChartConfig` shape again (issue #3729) + + #3701 trimmed `xAxis`/`yAxis`/`series` out of the `` contract + because the renderer read `xAxisKey`/`series[].dataKey` and silently dropped the + ChartConfig shapes — an honest record of the runtime gap, not the target state. + objectui#2880 closed the gap the other way round (the renderer now honors + `ChartConfig` through one normalization boundary), so the contract follows the + protocol again (ADR-0082 D1: the spec schema IS the protocol). + + **Contract.** `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`, + `annotations` and `interaction` are published from `ChartConfigSchema`; the + internal `chartType`/`xAxisKey`/`series[].dataKey` spellings leave the author + contract. `annotations` and `interaction` gained the `.describe()` they never + had, so the generated contract stops publishing bare `object[]` with no meaning. + + **The `type` exception.** `ChartConfig.type` is the chart family, but on any + surface that flattens chart config into a props bag `type` is already the SDUI + envelope's component discriminator — an author writing `type="bar"` used to + replace `object-chart` and the block stopped resolving. The collision is created + by the flattening and is resolved there (objectui's react-page wrapper), so the + contract can publish `type` as the spec spells it. The contract generator's + blanket `type` skip is now overridable by an explicit `dataProps` allow-list, + since for this one block `type` is a real author prop. + + **Lint.** `validate-react-page-props` reads the axes in the spec spelling — + `xAxis.field`, `yAxis[].field`, `series[].name` — and keeps accepting the + internal spellings silently, because dashboards and the console's own chart-view + wiring emit them. `react-chart-axis-inert` is retired: the props it warned about + are honored now, so the warning would be false. The three binding-integrity + rules from #3701 are unchanged. + + **Spec.** `chart-aggregate.ts` records the constraint the whole result-column + convention rests on: an inline `aggregate` is SINGLE-MEASURE. Keying rows by the + raw field name only works because there is exactly one measure to key; two + measures over one field would collide, and resolving that needs an author-chosen + name per measure — which is what a dataset is. Widening `ChartAggregateSchema` + into a measures array would silently invalidate every axis binding these rules + validate, so the boundary is now written down rather than left to be rediscovered. + + The chart taxonomy note is corrected too: grouped/stacked bar and stacked area + are absent from `ChartTypeSchema` not because they render as their base chart, + but because stacking is a property of the SERIES (`ChartSeries.stack`), not a + chart family — one `bar` family plus a series stack group expresses all three. + `ChartInteraction.zoom` is now marked declared-not-delivered in its own + description rather than reading as shipped. + +- 17749fc: Page-component field bindings and non-dashboard chart bindings (issue #3583, Phase 2) + + Two more reference-integrity rules from the #3583 assessment, both wired into + `os validate`, `os lint`, and `os compile`. + + **`validate-page-field-bindings`** — `PageComponent.properties` is an untyped + bag, so a highlights strip, KPI card, or details section can name a field the + bound object does not have; the component silently skips it. Which object a + component binds follows `dataSource.object` → `properties.object` → the page's + `object`, so multi-object pages are checked per element. `record:related_list` + resolves its columns/sort/filter against the **related** object and its + add-picker against that picker's own object. Advisory (matching + `FORM_FIELD_UNKNOWN`). Relationship paths, system fields, cross-package objects, + and unregistered component types are skipped. + + **`validate-chart-bindings`** — extends ADR-0021 axis checking past dashboards to + report charts (`report.chart` and `report.blocks[].chart`), list-view charts + (`views[].list`, `views[].listViews.*`, `objects[].listViews.*`), and + dataset-bound page chart components. An axis naming a raw field instead of a + declared measure is an **error** (the series comes back empty); an axis naming a + declared-but-unselected measure is a **warning**. The report shape needed its own + handling: `ReportChartSchema` narrows `xAxis`/`yAxis` to bare strings, which the + dashboard rule's array guard skips silently. The react `` block is + object-bound, not dataset-bound, and is deliberately left out — nothing defines + what its aggregate names the result column. + + **Fixes:** the page walk used by `validate-action-name-refs` read a top-level + `page.components` array, which `PageSchema` does not have — components live under + `regions[].components[]` and `slots`, and sub-trees nest inside the untyped + `properties` bag (`children`, `items[].children`, `body`, `footer`) rather than a + `children` key on the component. The rule was therefore visiting nothing on a + schema-parsed stack. Traversal now lives in one shared, tested module; on the + showcase app it reaches 194 components where the previous shape found 46. + Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are skipped — their + `regions` hold a derived cache the `source` wins over. + +- 4340f13: feat(lint,cli): flag flow `update_record` writes to readonly fields at design time (#3425) + + A flow `update_record` node that writes a field the target object declares + `readonly: true`, under the default `runAs: 'user'` identity, is a **silent + no-op**: the objectql engine strips static-`readonly` fields from a non-system + UPDATE payload (#2948), so the intended write never lands — yet the step still + reports `success`. #3407/#3413 surfaced the strip as a run-time step warning; + this moves the discovery **left** to `os validate` / `os build` so an author + finds the mismatch at design time instead of by reading server WARN logs days + later. + + - New `@objectstack/lint` rule `validateReadonlyFlowWrites(stack)` — a pure + `(stack) => Finding[]` check (ADR-0019). A static `readonly:true` field + written by a literal `update_record` under `runAs !== 'system'` is a + 100%-certain no-op → **error** (gates the build). A `readonlyWhen` field is + per-record-state → **warning** (advisory). Deliberately narrow to stay + false-positive-free: `create_record` (INSERT is engine-exempt from the strip), + `runAs: 'system'` flows (the intended "automation maintains it" channel), + templated object names, and non-literal `fields` maps are all skipped. + - Wired into `os validate` and `os compile`/`os build`, mirroring the existing + security-posture gate (errors fail; advisories print dimmed). + + The formal contract, unchanged in behavior: `readonly` governs the end-user / + API surface (REST/UI and `runAs:'user'` flows strip it); trusted system writers + (`runAs:'system'`, system hooks, seeds) maintain it. To let a flow maintain a + readonly field, declare `runAs: 'system'`. + +- f163028: Reference-integrity validation for object and action names (issue #3583) + + A HotCRM audit found ~20 shipped instances of one bug class — metadata naming + something that does not exist — all passing `objectstack validate` / `lint` + cleanly and failing silently at runtime. This closes the object-name and + action-name half of that class. + + **New — `@objectstack/spec`:** `PLATFORM_PROVIDED_OBJECT_NAMES`, a curated + registry of every object name contributed by a platform package, official + plugin, or the cloud runtime, plus `isPlatformProvidedObjectName()` and + `hasPlatformObjectPrefix()`. This replaces the `startsWith('sys_')` prefix guess + that could not tell `sys_user` (real) from `sys_approval_process` (fictional — + removed by ADR-0019, registered by nothing), which is why every fictional + platform-prefixed reference shipped. A conformance test scans each package's + `*.object.ts` declarations and fails if the registry drifts. + + **New lint rules** (wired into both `os validate` and `os lint`): + + - `validate-object-references` — action-param `reference` / `objectOverride`, + dashboard `globalFilters[].optionsFrom.object`, and navigation + `requiresObject` gates. Severity follows resolvability: an unresolved + _unprefixed_ name is a typo (**error** — `object: 'user'` where the platform + object is `sys_user`); an unresolved _platform-prefixed_ name is **advisory**, + since a third-party package may still provide it. + - `validate-action-name-refs` — the surfaces that bind an action BY NAME: + list-view `bulkActions` / `rowActions`, page `record:quick_actions` + `actionNames`, and nav action items. A name matching no defined action is an + **error** (the button renders and does nothing), matching the existing + dashboard-action-target rule. + + **Fixes:** + + - `defineStack` cross-reference validation now walks `app.areas[].navigation` — + an areas-based app previously got no navigation checking at all — and recurses + into `children` on `object` nav items, not only `group` ones. + - `os lint` i18n coverage now reads field `options` in the canonical + `{value,label}[]` array shape; it only handled the record map, so option-label + coverage silently never fired for canonically-shaped select fields. + - Hook `condition` expressions are now field-checked when `object` is an ARRAY + of targets (previously only a single string target was checked, so a + multi-target hook filtering on a nonexistent field passed clean). Per-target + diagnostics are de-duplicated. + - A dashboard widget binding no `dataset` at all is now reported instead of + silently bypassing every binding and chart check on the raw-config + (`lint`/`doctor`) paths. `dataset` is schema-required, so this matches what + the parsed paths already enforce. + +### Patch Changes + +- 1bd5652: feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the + `delegated_admin` org role, capped so it cannot mint authority (#3697) + + D8 authorizes invitation _placement_ against the issuer's `adminScope` + (ADR-0090 D12), so a delegated plant admin may invite only into their own + subtree. That gate is implemented, unit-proven and reachable — but no principal + could reach it in a state where it did anything: + + - better-auth grants `invitation: ["create"]` to `owner` and `admin` only + (`memberAc` holds `invitation: []`, which every other registered role + inherits); + - under a wall-enforcing posture, owners and admins are auto-elevated to + `organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard + `modifyAllRecords` that makes `isTenantAdmin()` true — and the gate + short-circuits on tenant admins. + + The two sets were disjoint. Issuance placement was bounded by the Layer 0 org + wall (real, and correct) but never by `adminScope`, so D8's motivating story — + "a plant admin invites into their own subtree without a platform admin + finishing the job" — could not happen. + + **Two pieces, and they only ship together.** + + **1. The role.** `delegated_admin` is now registered with the organization + plugin as `memberAc.statements` plus `invitation: ["create"]` — the one + membership grade that may reach `/organization/invite-member` without being an + org admin. Deliberately _not_ `invitation: ["cancel"]`: better-auth's cancel + route checks the permission with no inviterId attribution, so it would mean + "cancel anyone's pending invitation in the org". + + The role carries no ObjectStack authority by construction — `mapMembershipRole` + passes it through as a position name, and with no `sys_position_permission_set` + binding that name resolves to nothing. Role = _can reach the endpoint_; + `adminScope` = _what the endpoint permits_. + + `sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a + fourth option. Those selects are **enforced on write** — better-auth's own + invitation and membership inserts are validated like any other row — so + registering the role with the org plugin without listing it in both would have + produced a role nobody could hold and nobody could hand out + (`ValidationError: role must be one of: owner, admin, member`). That is exactly + how the end-to-end regression caught it, twice; neither unit test could. The + three non-English translation bundles carry the English label for the new option + until localized. + + **2. The role cap**, in the framework's own `beforeCreateInvitation` hook, + beside the D8 placement gate. Registering the role alone would have been a + four-step privilege escalation: better-auth's only role-level cap on _what role + you may invite someone as_ is its `creatorRole` check (default `owner`), which + blocks inviting an **owner** but not an **admin** — and an accepted `admin` + membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A + subtree-scoped delegate could have manufactured a tenant admin, with every + existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the + acceptance-time membership write runs under better-auth's context, not the + issuer's). + + The cap refuses an invitation whose role outranks the issuer's own, and + restricts a below-admin issuer to plain `member` — not merely "not admin/owner", + because an app-registered role projects into `current_user.positions` and may be + bound to permission sets, making it a capability channel too. A delegate's + channel for capability is the invitation's _placement_ intent, which the D12 + gate allowlists position-by-position. The cap applies to every invitation, + placement-carrying or not (the escalation is independent of placement), and + fails closed: an issuer role that cannot be resolved confers nothing above a + plain member. + + **What changes for deployments.** One new class of principal exists: members + holding the `delegated_admin` org role, who can invite into the org — as + `member` only, into the subtree their `adminScope` allows. It is opt-in twice + over (someone must set the membership role _and_ grant an adminScope set), so a + default deployment changes not at all. Org owners and admins are unaffected. + + Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so + console and control-plane surfaces name the role from one place. + +- 9dcc0ae: fix(automation): array-form flow `triggerType` fails loudly instead of silently never firing (#3481) + + An array `triggerType` on a flow start node — the shape an author (or an AI + authoring pass) naturally reaches for to fire on more than one event, e.g. + + ```ts + config: { objectName: 'app_task', triggerType: ['record-after-create', 'record-after-delete'] } + ``` + + was accepted everywhere and armed nowhere. Multi-event unions are deliberately + unsupported (only the single tokens plus the `record-after-write` create-OR-update + union exist — see #3457), but nothing said so: `defineFlow` passed the array + (start-node `config` is an open record), the engine's `typeof === 'string'` check + folded it to no trigger and misclassified the flow as **manual**, so it never + entered the trigger-binding audit, and the flow-trigger-readiness lint used the + same `typeof` narrowing and produced no finding. The flow bound to nothing and + never fired, with zero output at any layer — the same silent-never-fire class as + #3427 / #3472, and the last authoring shape still slipping past every guard. + + This is a **defensive** fix — arrays remain unsupported; they now fail loudly: + + - **lint** (`validate-flow-trigger-readiness`): an array `triggerType` containing + any `record-*` element now yields a `flow-trigger-unknown-event` warning at + `os validate` time, steering to `record-after-write` (for created-or-updated) or + one flow per event. + - **engine** (`resolveTriggerBinding`): such an array is routed to the + `record_change` trigger — exactly as an unmappable single token is — instead of + being folded to a manual flow, so it reaches the trigger's bind-time rejection. + - **trigger** (`record-change`): the bind-time rejection detects the array shape + and emits a targeted warning (naming the flow, pointing at `record-after-write` + and #3457) rather than the generic unknown-token line. + +- 5b89711: feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574) + + A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a + zero, indistinguishable from a metric that is legitimately empty, with nothing + in the console or the server log. `service_dashboard.my_open_cases_by_priority` + in the HotCRM template had shipped broken this way since the day it was + written. + + The token had never been part of the contract. Date macros were frozen in + `date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a + single client resolver; `{current_user_id}` had only prose in an `app.zod.ts` + JSDoc and three ad-hoc client implementations that each handled one surface's + filter shape. Nothing could tell an author their token was wrong. + + - **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing + `CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of + `DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` / + `classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The + module documents what the tokens are _not_: presentation scope, never an + access boundary — that is RLS, which uses the unrelated `current_user.id` + expression root. + - **`@objectstack/lint`** — new `validateFilterTokens` (rule + `filter-token-unknown`, severity `error`). It walks `filter` / `filters` / + `runtimeFilter` subtrees across dashboards, objects, views, reports, + datasets, pages and apps, and reports any placeholder that resolves in + neither vocabulary. It scans for filter _keys_ rather than enumerating known + surfaces, so a new surface following the convention is covered the day it + ships — enumerating surfaces is how the dashboard was missed in the first + place. Navigation `recordId` / `params` are deliberately out of scope: they + resolve `AppContextSelector` ids, which are meaningless in a filter. + - **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`. + + It is an error rather than a warning because of who authors this metadata. An + AI reads a query returning `0` as a correct answer and builds on it; its + correction loop is author → validate → fix, so a diagnostic only reaches it if + it can fail the build. The three spellings the suggestion table covers — + `{current_user}`, `{user_id}`, `{organization_id}` — are each correct + _somewhere else_ in the platform, which is exactly why authors reach for them. + + Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token + that resolves nowhere. + +- de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810) + + Three related holes, all of which end in "the query matched rows the author + excluded". + + **1. A flow filter could silently widen to match everything.** + + The flow template interpolator expresses "this token did not resolve" as + `undefined`. In a message that renders as empty text — harmless. In a FILTER it + removes the condition, and a removed condition matches MORE rows. When it was + the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to + `deleteMany` is every row in the table. + + So one mistyped field name in a `delete_record` node silently emptied the + object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the + run never received, a lookup hop (`{record.account.name}` — the trigger record + carries a scalar id), and a filter placeholder. + + `get_record` / `update_record` / `delete_record` now refuse to execute when + interpolation erased any authored condition, naming the offending template. The + guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is + unaffected, and losing one of two conditions still fails, because widening from + "my open records" to "all open records" is the same class of bug. + + **2. Filter placeholders never reached the engine that resolves them.** + + `config.filter` is where two `{…}` dialects meet — the flow template dialect + (`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`, + `{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order + picked the winner by accident: the flow interpolator ran first, found no flow + variable by that name, and erased it. + + `interpolateFilter()` hands that position back to the dialect that owns it — a + whole-string token that no flow variable resolves and that IS a recognised + placeholder passes through verbatim for the engine to expand. Flow variables + keep precedence, so a template that works today cannot change meaning. + + **3. The engine resolved placeholders on reads but not on writes.** + + `resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So + the SAME filter selected different rows depending on the verb: `find({ owner: +'{current_user_id}' })` matched the signed-in user's rows, while + `update`/`delete` compared the literal token text and matched none — a flow that + previewed with one and acted with the other operated on two different row sets. + This is the #3106 shape one layer down: the evaluator existed, only some call + sites reached it. + + `update` and `delete` now resolve too, BEFORE the by-id fast path claims a + scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as + the primary key itself). Caller options are never mutated. + +- 5524f84: feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475) + + A record-change flow can now declare `expand: ['', …]` on its start + node config so node templates resolve `{record..}` (e.g. + `{record.account.name}` in a notify title, closing the #3426 gap for lookups). + + The engine re-reads the declared relations AFTER identity resolution, as the + run's OWN principal — `resolveRunDataContext` honors `runAs`, so a `runAs:'user'` + run reads the referenced object as the **triggering user** (its RLS/FLS enforced) + rather than system-elevated. This is what made expansion unsafe to do in the + trigger's re-read (which has no resolved grants) and is why it lives in the + engine (new `AutomationEngine.setRecordExpander`, bridged by the plugin to the + same data engine the CRUD nodes use). + + Only the declared relation keys are grafted onto the run record, so bare lookup + ids and `multiple` lookup arrays (#1872) on other relations — and the formula + fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when + unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never + breaks the flow. + + The `os validate` lint rule `flow-template-lookup-traversal` (#3426/#3472) is now + suppressed for a relation once the flow declares it in `config.expand`. + +- 169b58a: fix(#3426): build-time warning for unresolvable flow template paths + guard the formula re-read + + Two follow-ups to #3426 (the formula/lookup `{record.}` template gap that #3445 began closing). + + **Build-time signal (the issue's fallback ask).** `os validate` now flags a + record-change flow node whose `{record.}` template cannot resolve — + turning the previous SILENT blank into an advisory warning. Two cases, via the + new `@objectstack/lint` rule `validateFlowTemplatePaths`: + + - `flow-template-unknown-field` — `{record.}` where `` is neither a + declared field nor a system column (a typo like `{record.full_naem}`). + - `flow-template-lookup-traversal` — `{record..}`, a cross-object + hop the seeded record carries only as a scalar id (still unsupported; tracked + on #3426). + + Deliberately quiet: formula fields, bare lookup ids, numeric indexes into + `multiple` lookups (#1872), `json` sub-paths, and system columns are NOT flagged, + and flows bound to an object this stack does not define are skipped (no schema to + compare against). + + **Hydration re-read guards.** The `trigger-record-change` computed-field re-read + (#3445) is now (a) skipped when the object declares no `formula` field — the only + thing it adds — via the engine's optional `getObjectConfig`, and (b) memoized per + write on the shared HookContext, so N flows on one written record share ONE + re-read instead of N. Any uncertainty falls back to the prior unconditional + re-read (correctness over the optimization). + +- 7f4a8a1: fix(lint): flag every never-firing `record-`-prefixed trigger token, incl. `record-change` (#3427) + + Generalizes the `flow-trigger-unknown-event` rule: it now flags ANY `record-`-prefixed + `triggerType` that is not a valid firing token + (`record-{before,after}-{create,insert,update,delete,write}`) — not just + `record-(before|after)-` typos. This closes the `record-change` trap: the + engine routes `record-change` ("Record changed (any)") to the record-change trigger, + which maps it to no hook so it never fires — now caught at `os validate` time instead + of only a runtime warn. Also covers bad-phase tokens like `record-during-update`. + Warning severity, unchanged. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- 29ff3c2: feat(lint): warn on replay-unsafe `mode: 'insert'` seed datasets (#3434 follow-up) + + Seeds are replayed — they re-load on every dev-server boot and every package + re-publish, not applied once — so `mode: 'insert'` (the loader's one mode with + no existing-row check) duplicates its table on every restart. That footgun + shipped undetected until #3434 (showcase memberships grew 3 → 6 → 9). + + Adds `validateSeedReplaySafety` to `@objectstack/lint` (a pure `(stack) => Finding[]` + rule, ADR-0019) and wires it into `os validate` / `os lint`. Every `data[]` seed + declared with `mode: 'insert'` now gets an advisory warning that points at the + idempotent modes (`ignore` / `upsert`) and the `externalId` to match on — a + single natural-key field, or a COMPOSITE list of fields for a join / junction + table with no single key (`['team', 'project']`, the support #3434 added). It + catches the mistake at authoring time instead of on the second boot. + +- 95829a0: feat(lint): warn on seed values outside an object's declared state machine (#3433 follow-up) + + #3433 exempts seed writes from the `state_machine` validation rule, so a seeded + status the FSM does not declare is no longer rejected at write time. A field-level + `select` still catches a value outside its `options`, but a `state_machine` on a + free-text field — or a value that is a valid option yet not a declared FSM state — + now sails through silently: the exemption is a deliberate but blind back door. + + `validateSeedStateMachine` (a pure `(stack) => Finding[]` rule, run from + `os validate` / `os lint`, symmetric with the replay-safety rule from #3434) + re-adds that safety net at author time. It flags any seed record whose + `state_machine`-governed field carries a value outside the machine's declared + states — the union of `initialStates`, the transition-map keys, and the transition + targets. Advisory (`warning`): the exemption itself is legitimate, so the fix-it + points at either adding the state to the machine or correcting the typo, not a hard + build failure. New rule id: `seed-value-outside-state-machine`. + +- 57bab76: Typed `decisionOutputs` declarations (#3447 follow-up). A `decisionOutputs` entry may now be `{ key, label?, type: 'text' | 'user' | 'department' | 'position' | 'team', multiple? }` alongside the bare-string form — a typed entry tells the decision UI to render the matching record picker (id values; `multiple` collects an id array) instead of free text, turning "paste user ids" into "pick people". The type shapes only the input widget: the runtime whitelist works by `key` either way, via the new `normalizeDecisionOutputs` helper exported from `@objectstack/spec/automation` — the single reader of the union shape shared by the service, the request read, and `os lint`. The request read now carries `decision_output_defs` (normalized declarations) alongside the version-skew-safe `decision_outputs` key list. +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/sdui-parser@17.0.0-rc.0 + ## 16.1.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 9054c10058..d960959d6b 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "16.1.0", + "version": "17.0.0-rc.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 3da529abe0..be39d0091e 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,251 @@ # @objectstack/plugin-mcp-server +## 17.0.0-rc.0 + +### Patch Changes + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7e9cd91791..ce0a83c615 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "16.1.0", + "version": "17.0.0-rc.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 8113f1aae2..6aed14e89b 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,174 @@ # @objectstack/metadata-core +## 17.0.0-rc.0 + +### Patch Changes + +- db48ad5: fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026) + + The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch + request is admitted only when the object grants the `bulk` **primitive** and the + batched child operation is itself allowed. Before that, the `*Many` routes + checked only the child verb, so a boilerplate CRUD-five whitelist + (`['get','list','create','update','delete']`) batched fine. + + The companion fix — adding the `bulk` primitive wherever an explicit whitelist + survived — was applied only inside `platform-objects`. Eight objects carrying + the same boilerplate live in other packages and kept the gap, so `/batch`, + `createMany`, `updateMany` and `deleteMany` answered `405 +OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/ + delete were wide open. `data-objectstack` rethrows that 405 without falling back + to per-row writes, which surfaced as a hard error on multi-select delete in the + Setup grids. + + Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`): + `sys_capability`, `sys_permission_set`, `sys_position`, + `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position` + (plugin-security); `sys_approval_delegation` (plugin-approvals); + `sys_view_definition` (metadata-core). + + No new authority is granted: `bulk` only permits batching verbs each object + already exposes one record at a time, and every batched row still passes the + same row- and field-level permission checks. The whitelists stay explicit rather + than being deleted — seven of the eight are `managedBy`, and + `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array + `apiMethods`, so dropping the line would silently disable the managed-write + backstop. + +- c073b8c: refactor(metadata-core): drop `sys_view_definition`'s all-six `apiMethods` whitelist (#3026) + + #3745 completed this object's boilerplate CRUD-five whitelist to all six + primitives so its batch routes stopped 405-ing. A whitelist naming all six is + equivalent to no whitelist — except it stops tracking primitives the enum grows + later — so the #3543 audit rule applies and the declaration is removed. + + No behaviour change: `undefined` resolves to `unrestricted`, whose effective + operation set is identical to `restricted` holding all six. + + Removing it is safe HERE specifically because the object has no `managedBy`: + `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array + `apiMethods`, so for a managed object an absent whitelist would take the + managed-write backstop with it. That is why the RBAC objects reclaimed by #3745 + keep their explicit arrays and this one does not. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 51ea7a532c..8024cfb9f6 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "16.1.0", + "version": "17.0.0-rc.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 1dcfa12b9f..d872cd2203 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/metadata-fs +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [db48ad5] +- Updated dependencies [c073b8c] + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 7873e1aa3a..45ed65d848 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "16.1.0", + "version": "17.0.0-rc.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 489dd6124c..c6ccc0404a 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,466 @@ # @objectstack/metadata-protocol +## 17.0.0-rc.0 + +### Minor Changes + +- 3949a43: fix(metadata-protocol,rest): the data path really 404s unknown objects now (#3770) + + The REST API-exposure gate (`enforceApiAccess`) passes through any object it + cannot find in metadata, and the comment there justified that with + `// unknown object → let the data path 404`. That fallback did not exist. + + - `findData` — and every other data entry point except `cloneData` — had **no + existence check**. The repo's only `OBJECT_NOT_FOUND` throw was in `cloneData`. + - The engine does not reject unregistered names either: `resolveObjectName` + falls back to `StorageNameMapping.resolveTableName({ name })`, so the object + name is used **as the table name**. + - The 404 was therefore only ever a side effect of the **driver** erroring on a + missing table, which the REST layer recognised by matching the driver's error + string. + + So the 404 held only when the table happened not to exist. When a physical table + with that name **did** exist — out-of-band DDL, a registration that failed after + `syncObjectSchema` had already run, a registration race — the exposure gate was + silently skipped and the rows were served, with no layer turning it into a 404. + (Since #3545 an authenticated caller on a plugin-security deployment is refused + by the fail-closed posture check; anonymous callers and deployments without + plugin-security were not.) + + **The gate.** `ObjectStackProtocolImplementation` now runs a shared + `assertObjectRegistered` before storage is touched, on `findData`, `getData`, + `createData`, `cloneData`, `updateData`, `deleteData`, `batchData`, + `createManyData`, `insertManyData`, `updateManyData`, `deleteManyData` and + `analyticsQuery`. An object absent from the schema registry is rejected with + `OBJECT_NOT_FOUND` / 404 — an authoritative answer from the registry, raised + _before_ the name becomes a table name, instead of an inference from driver + prose. `cloneData`'s open-coded check is now that shared gate; its envelope is + unchanged. + + It sits at the protocol ingress, the same boundary `apiEnabled` guards: internal + callers (hooks, flows, migrations, raw ObjectQL) go to the engine directly and + are unaffected. When the engine exposes no schema registry at all there is + nothing to consult, so the gate stands down and warns once per process — + matching the tiering #3545 recorded in `api-exposure.ts` for a whole-registry + outage. + + **Behaviour change.** A REST data request for an object that is not in the + schema registry now returns `404 object_not_found` even when a table of that + name exists. Previously it returned that table's rows. If a deployment depended + on reading a table with no registered object, register the object (its schema is + what every other layer — exposure, RBAC/FLS/RLS, field projection — already + needs in order to enforce anything at all). + + **One wire code.** `mapDataError` maps the protocol's `OBJECT_NOT_FOUND` to the + canonical `object_not_found` `ApiErrorCode` — byte-identical to the envelope the + driver-string branch already produced — so a client keying on `code` sees _what + happened_, not _which layer noticed_. The driver-string branch stays as the + safety net for the other failure it actually covers: an object that IS registered + but whose physical table is missing. Callers that were reading `cloneData`'s 404 + as `code: 'OBJECT_NOT_FOUND'` on the wire now get `object_not_found`; the status + is 404 either way. + + The misleading comment is replaced with what actually closes the hole — this + gate for existence, plugin-security's `unresolved` posture (#3545) for + authorization — and a note not to widen the exposure gate on the assumption that + some other layer 404s. + +- c2d9098: feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + + Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already + surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` + #3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did + not — the same strips happened silently on every batched row — and the typed + client warning + CORS mirror were deferred. This closes those out. + + **Bulk passthrough (metadata-protocol).** + + - `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. + - `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. + - `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. + - **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + + **Contract (spec).** `BatchOperationResultSchema` gains an optional per-row + `droppedFields` (covers `updateMany` + `batch`, which alias + `BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional + aggregated `droppedFields`. Both are omit-when-empty, so existing clients are + unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for + batches — one response header cannot express per-row drops, so the per-row body + field is the canonical bulk channel. + + **Typed client warnings (@objectstack/client).** `CreateDataResult` / + `UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body + channel a type instead of an untyped property. + + **CORS (@objectstack/hono, @objectstack/plugin-hono-server).** + `x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` + allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin + browser can read the single-write drop header. The body `droppedFields` remains + the primary, cross-origin-safe surface — this is a convenience mirror. + + **GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, + but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and + `handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There + is no schema generator or mutation resolver to expose a typed payload field on, + so there is nothing to wire until a GraphQL engine lands — at which point the + protocol-layer `droppedFields` is already present and only the GraphQL schema + projection would remain. + +- 5ac93d4: feat(rest): surface silently-dropped write fields on PATCH/POST /data (#3431) + + #3413 (closes #3407) built the engine-level strip-observability channel + (`WriteObservabilityOptions.onFieldsDropped`) and wired the flow side + (`update_record` / `create_record` emit a step warning + `droppedFields`). The + **REST write path was never wired**, so an external API caller writing N fields + still got a bare `200 + record` when `readonly` (#2948) / `readonlyWhen` (#3042) + stripping meant `< N` actually landed — the same silent-success class #3407 + fixed flow-side, just on HTTP. The only way to notice was a per-field diff of + the returned row (which need not echo every field). This wires the channel + through the protocol → REST, on both write verbs. + + **Passthrough (metadata-protocol).** `updateData` now registers an + `onFieldsDropped` collector on `engine.update` and returns the events on the + response as `droppedFields`. `createData` surfaces the #3043 static-`readonly` + INGRESS strip too — that strip runs at the protocol ingress + (`stripReadonlyForInsert`), _before_ the engine, so it is recovered by diffing + the supplied payload against the stripped one (the engine's `onFieldsDropped` is + also wired for a future insert-side engine strip). A faulty listener never + breaks the write — the engine catches and logs. + + **Contract (spec).** `UpdateDataResponseSchema` / `CreateDataResponseSchema` + gain an **optional** `droppedFields: DroppedFieldsEvent[]` — present only when + ≥1 field was dropped. Optional + omit-when-empty keeps the response shape + backward-compatible for clients that only read `record`. + + **REST surface.** PATCH `/data/:object/:id` and POST `/data/:object` echo the + drops as an `X-ObjectStack-Dropped-Fields` response header + (`field;reason=` tokens, comma-joined — e.g. + `approval_status;reason=readonly`) and keep the structured `droppedFields` on + the body. **Status/success semantics are unchanged** (200 update / 201 create) — + a strip is legitimate semantics, not a failure (same principle as #3413). The + FLS write gate is untouched (it already fails closed with 403). + + Out of scope (issue #3431 D2 open questions, deferred): bulk + (`updateManyData` / `createManyData` / `batchData`) and GraphQL mutation wiring, + typed `@objectstack/client` warnings, and adding the header to the Hono CORS + `exposeHeaders` allow-list for cross-origin browser reads (the body + `droppedFields` is the cross-origin-safe channel meanwhile). + +- 20cb232: feat(metadata-protocol,objectql): MetadataProtocolPlugin + `registerProtocol` opt-out — ADR-0076 Step 2 PR-A (#2462) + + `createMetadataProtocolPlugin()` now owns what `ObjectQLPlugin` historically + assembled inline: the `ObjectStackProtocolImplementation` construction + + `protocol` registration, the metadata-storage platform objects, and the D12 + `degraded` analytics fallback (pattern: plugin-security — named plugin, + `dependencies` on the engine, `ctx.getService('objectql')`). `ObjectQLPlugin` + grows `registerProtocol?: boolean` (default `true`, fully backward + compatible): pass `false` when mounting the new plugin. Protocol CONSUMERS + stay on the engine plugin either way — DB hydration and the authored + hook/action rebind resolve `protocol` lazily (the rebind arms from `start()` + in delegated mode) and degrade gracefully. Mixing both assemblies fails fast + with the fix in the message. This is the additive first leg of the + cross-repo sequence; cloud's 3 boot sites flip in PR-B, the built-in + assembly + re-exports retire in PR-C. + +- e231abb: feat(objectql,metadata-protocol)!: single-source the protocol assembly; drop objectql's protocol re-exports — ADR-0076 Step 2 PR-C (#2462) + + The ONE assembly now lives in `@objectstack/metadata-protocol` as + `assembleMetadataProtocol()` — `createMetadataProtocolPlugin()` (delegated + mode, cloud) and `ObjectQLPlugin`'s built-in convenience mode + (`registerProtocol !== false`, single-kernel/dev boots) both mount the same + code path (~112 inline lines deleted from the engine plugin). objectql's six + protocol re-exports (`ObjectStackProtocolImplementation`, + `SysMetadataRepository`, `SeedLoaderService`, `runBuildProbes` + types) are + removed — import them from `@objectstack/metadata-protocol` directly + (breaking, shipped as minor per the launch-window convention; the only known + importers were five test files, repointed). Scope note vs the original Step-2 + recipe: the objectql→metadata-protocol dependency is deliberately KEPT for + the convenience mount — `@objectstack/objectql/core` was already + protocol-free, and forcing 20 framework boot sites to mount two plugins buys + no runtime win. "Zero protocol dependency" lands as "zero assembly ownership, + single source". + +### Patch Changes + +- abceb0d: fix(seed-loader): support a composite `externalId` so join-table seeds dedupe on replay (#3434) + + A junction / join table has no single-field natural key — the PAIR of its + foreign keys is what's unique — so its seed could only run `mode: 'insert'`, + which re-inserts every row on each replay boot with no existing-row check + (`decideWriteAction`'s `insert` case returns `insert` unconditionally). The + table duplicated on every restart: the showcase `showcase_project_membership` + fixture (3 rows) grew 3 → 6 → 9. It was masked until #3415 let the master-detail + parents seed at all. + + - `SeedSchema.externalId` now accepts a **list** of field names + (`externalId: ['team', 'project']`) in addition to a single field name, + declaring a composite natural key. Default stays `'name'`. + - `SeedLoaderService` builds the uniqueness key from all listed fields (joined + with a `\u0000` separator that can't occur in a natural-key value). Reference + key fields are compared by their RESOLVED parent ids — which the existing DB + row already stores — so a composite of foreign keys matches across restarts. + A partial key (any component absent) is treated as no key, falling back to + insert, exactly as a missing single-field key already did. + - A composite-key target does not participate in single-value reference + resolution (a reference is one natural-key string), so such objects keep the + `'name'` default when referenced by another dataset. + + The showcase membership fixture switches to `mode: 'ignore'` + + `externalId: ['team', 'project']`, so replay boots leave the three rows + untouched instead of duplicating them. + +- 4c5a584: fix(seed-loader): resolve lookup/master_detail references for objects that only live in the engine registry (marketplace installs) + + `SeedLoaderService.buildDependencyGraph` consulted only `metadata.getObject()` + when building the reference graph. Marketplace-installed packages register + their objects through the `manifest` service straight into the ObjectQL + registry — after the boot-time `bridgeObjectsToMetadataService` pass — so the + metadata service never lists them. The reference graph came back empty for + those objects and every lookup / master_detail seed value was written + verbatim: `crm_contact.crm_account` held the authored natural key + (`"Acme Corporation"`) instead of the target record's id. + + The damage compounded under RLS: `crm_contact` declares + `sharingModel: controlled_by_parent`, whose row filter compiles to a join on + the parent reference. With every reference dangling, the join matched nothing + and the whole object went invisible to everyone — platform admins included — + while the rows sat in the table (REST list `total=0`, single GET 404). + + The loader now falls back to the engine's own schema registry + (feature-detected `engine.getSchema()`, which the ObjectQL engine exposes) + whenever the metadata service has no definition for a seeded object. The + metadata service remains the preferred source; engines without a schema + registry keep the old behavior. + +- 0c302a7: Exempt curated seed writes from `state_machine` validation (#3433). + + A seed is a snapshot of established facts — a project already `completed`, an + opportunity already `closed_won` — not a record walking its lifecycle. But once + an object declared `state_machine.initialStates` (#3165), the write path enforced + the FSM entry point on **every** insert, so seed replay silently rejected every + mid-lifecycle row and cascaded its master-detail children. That is the "installed + but no data" failure for the showcase board (1 of 5 projects), and it would hit + every marketplace template (a `closed_won` opportunity, a `closed` case) plus the + rehydrate-heal and per-org replay paths. + + `SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay` + flag; the engine passes `skipStateMachine` to the rule evaluator for those writes, + which skips the `state_machine` rule on both insert (`initialStates`) and update + (transitions). The exemption is scoped to `state_machine` only — a seed must still + satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`, + `conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`, + the fix covers boot inline seed, marketplace install/heal, and per-org replay at once. + + The showcase project seed drops its three-phase FSM-walk workaround (#3415) and + seeds each project directly at its real status again. + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 37d961d44a..d85aec4acf 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "16.1.0", + "version": "17.0.0-rc.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 d511cdf5f3..9ce1238539 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,156 @@ # @objectstack/metadata +## 17.0.0-rc.0 + +### Patch Changes + +- 4e9e184: chore(deps): OSV security batch — bump tar to ^7.5.21 (GHSA-r292-9mhp-454m) and + js-yaml to ^5.2.2 (GHSA-pm4m-ph32-ghv5) + + Both are declared-range bumps to the patched releases, so downstream installs + resolve the fixed versions from the published manifests, not just this + workspace's lockfile. The same batch clears the remaining transitive advisories + (next 16.2.11 in apps/docs; workspace overrides for brace-expansion, sharp, + react-router, @sveltejs/kit, @hono/node-server) — those live in pnpm-workspace.yaml + and the private docs app, which do not ship. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + - @objectstack/metadata-fs@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 411cd3402f..c88185368e 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "16.1.0", + "version": "17.0.0-rc.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 ea02856f10..1b2974ca20 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1356 @@ # @objectstack/objectql +## 17.0.0-rc.0 + +### Minor Changes + +- 6169615: feat(objectql)!: media value shapes enforce once THIS deployment has verified its file migration (#3438 D1 media half, gated by #3617) + + A `file` / `image` / `avatar` / `video` / `audio` value that does not match the + stored contract (an opaque `sys_file` id) now **rejects with `invalid_type`** + instead of warning — but only on a deployment that has run + `os migrate files-to-references --apply` and passed its self-check. + + **Why this is not a version-wide flip.** The legacy media values this rejects — + inline `{url, name, …}` blobs, bare URLs — are exactly what that migration + converts. A deployment that has run it has been _shown_ to hold none; a + deployment that has not would have every media-field update start failing the + moment it upgraded. So the enforcement follows the evidence, per deployment, + rather than the release. Nothing changes for a deployment until it migrates. + + **Upgrading:** + + ```bash + os migrate files-to-references # dry run: reports what would convert + os migrate files-to-references --apply # convert, verify, record the flag + ``` + + If a write starts failing after you migrate, the value genuinely does not match + the contract — the error names the field. `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens + media leniency while you diagnose. + + **Scope — deliberately only media.** `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` is + unchanged and still opts every class into strict (and still forces media strict + on a deployment that has not migrated). Reference types (`lookup`, `user`, …) + and structured JSON (`location`, `address`, `repeater`, …) stay warn-first: the + file migration is evidence about file values and says nothing about whether a + `location` is well formed, so gating them on its flag would be borrowing + evidence for a fact it does not cover. They flip when something can vouch for + them — see #3438. + + **Cost.** Dormant unless the written object declares a media field, and the + flag read is memoized, so this is one query per process for apps that store + files and zero for those that do not. A running server picks up a + newly-recorded migration on restart, or via `engine.invalidateDataMigrationFlags()`. + +- fa3d0cf: feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) + + `@objectstack/spec/data` now owns the runtime VALUE shape of every field type + (`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`, + `NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`, + `STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared + `isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The + four consumers that each hand-copied this knowledge (objectql record-validator, + rest import-coerce, driver-sql column classification, qa conformance) now + derive from the spec, and the field-zoo round-trip MATRIX is asserted against + the contract so the two cannot drift. + + **Write-path change (objectql, warn-first):** previously-unvalidated types — + single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/ + `video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`, + `vector` — are now checked against the contract. A violation **logs a warning + and passes** in this release (legacy rows must not strand their records); + set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a + `400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor + (ADR-0104 R1/R2). + + **Deprecations (removal rides the next spec major), FROM → TO:** + + - `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's + value is a **bare number** everywhere in the runtime (validator, SQL `float` + column, import coercion, field-zoo oracle); the currency code lives in field + config. Use `valueSchemaFor({type: 'currency'})`. + - `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema` + (`{lat, lng}`) — the shape the platform actually stores. + - `AddressSchema` is **adopted** (unchanged) as the enforced `address` value + contract via `AddressValueSchema`. + + No stored data changes shape; the contract codifies deployed reality + ("reality wins", ADR-0104 D1). + +- a749273: feat(objectql): resolve file-field id references on read — ADR-0104 D3 wave 2 (PR-2) + + The engine read path now resolves a `file`/`image`/`avatar`/`video`/`audio` + value stored as an opaque `sys_file` id string into its expanded + `FileValueSchema` form — `{ id, name, size, mimeType, url }`, with `url` derived + from the stable `/api/v1/storage/files/:fileId` resolver (never stored). One + batched `sys_file` `id $in […]` read per query (no N+1), mirroring the + lookup-`$expand` batch pattern. + + **Dual-mode safe.** An inline-blob value (an object) passes through unchanged, + and only an **opaque id token** (uuid/nanoid-shaped) is treated as a reference — + a URL-shaped value (`https://…`, `/api/…`, `data:…`, `blob:…`), which a file + field legitimately holds in the legacy world, is never looked up. The step + fires zero reads unless a file field actually holds an id token (the blob/URL + case is free), and it no-ops entirely when `sys_file` is not registered. + + This makes a stored `fileId` (surfaced by PR-1) actually usable on read, ahead + of the v17 cutover that narrows the stored form to an id. + +- fdb4f50: feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617) + + The ADR-0104 file-as-reference migration ships as a command a deployment runs + against its own database, and the deployment-level flag it records is what may + later authorise irreversible behaviour — never the platform version. + + ```bash + os migrate files-to-references # dry run: reports, writes nothing + os migrate files-to-references --apply # converts, verifies, records the flag + ``` + + The run backfills legacy file-field values (inline metadata blobs, own-resolver + URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership + ledger against what records actually hold, and — only on an `--apply` run whose + reconciliation reports **zero blocking discrepancies** — records + `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. + + **Why a flag rather than a release note.** ObjectStack is a development + platform: third-party deployments upgrade on their own schedule and their data + is not observable by anyone else, so no release-side soak can vouch for them. + The evidence has to be produced where the data is. Consequences: + + - Installing a new version never starts deleting bytes. Running the migration + and passing its self-check is the consent. + - Not run, or not passed → files are retained forever. Wasted storage, zero + data loss. + - A later failing run **clears** `verified_at`: a deployment whose data has + drifted closes its own gate. + - A dry run writes nothing at all — not the conversions, and not the flag, + even when the self-check would pass. + - External URLs stay advisory. They are not `sys_file`s, so they can never + enter collection; whether to remodel them as a `url` field is the app + author's decision (ADR-0104 R7), not a gate. + + Ships alongside: + + - `@objectstack/spec` — `DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`, + and the single `isDataMigrationFlagVerified` predicate both future consumers + (collection #3459, strict value-shape #3438) read, so the two gates cannot + disagree about the same fact. + - `@objectstack/platform-objects` — the `sys_migration` object plus + `readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`. + Reads fail toward "not verified": a gate that cannot read its evidence stays + closed. + - `@objectstack/objectql` — a read may now opt out of file-reference expansion + via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's + bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to + their expanded form before the reconciliation sees them, which reports held + references as absent — noisy `stale_owner` findings, and a missed + `unowned_reference` would have been a false pass of the collection gate. + +- 48c110e: feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) + + #3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two + gaps survived that fix, both in the cases that still boot — a policy denial, an + `autoConnect` datasource, or any failure the operator waved through with + `OS_ALLOW_DRIVER_CONNECT_FAILURE`: + + - **It was invisible.** `DatasourceSummary.status` was the literal `'unvalidated'` + for every row — the contract declared three states and the implementation only + ever emitted one — so a dead datasource looked exactly like a healthy-untested + one. `checkDriversHealth()` could not help either: it iterates registered + drivers, and a datasource that never connected was never registered, so it is + _absent_ from the probe rather than unhealthy. The only trace was a warning + that scrolled past at boot, which made the diagnostic procedure "restart the + server and re-read the logs". + - **The query-time error said nothing.** `getDriver()` answered four different + situations with one sentence, `Datasource 'x' is not registered.`: refused by + policy, failed to connect under the escape hatch, a misspelled name, and + `active: false`. Only the third is an authoring bug, so the other three sent + the reader hunting for a typo that does not exist. + + Both come from the same root: `connect()` already produced a `ConnectResult` for + every attempt and every caller threw it away. + + - **`DatasourceConnectionService` retains the last verdict per datasource**, with a + coarse `availability` (`available` / `blocked` / `failed` / `unattempted`) beside + the raw status. New `getConnectionState(name)` / `listConnectionStates()`. + `disconnect()` drops it, so a removed pool stops explaining itself. + - **`DatasourceSummary.status` tells the truth**: `ok` | `error` | `blocked` | + `unvalidated`, with a new operator-facing `statusReason`. `blocked` is new and + deliberate — a policy denial is a decision, not a fault, and will not clear on + its own. Reported in **Setup → Datasources**, `GET /api/v1/datasources`, and the + summary returned from create/update, so a "Save" whose pool failed to open is no + longer presented as success. + - **`ERR_DATASOURCE_UNAVAILABLE` (HTTP 503)**: new `DatasourceUnavailableError` + from `@objectstack/objectql`, thrown by `getDriver()` when the connection layer + recorded _why_ a declared datasource has no driver. An undeclared name keeps the + original message — there is genuinely nothing to add. 503 rather than 500/400: + nothing about the request is wrong, and the state may clear. + - **A privileged/public split for the reason.** The error **never** carries the + underlying cause — connect failures routinely contain hosts, ports and DSNs, and + a policy's `reason` is written for operators. Those stay in the logs and the + (admin-gated) datasource list. `DatasourceConnectDecision` gains an opt-in + `publicReason` for hosts that want to tell tenants something specific + (e.g. `'External datasources require the Scale plan.'`); it is the only string + that reaches an end user. + - **Readiness is deliberately not gated on this.** `/ready` still reflects + registered-driver health only: an optional datasource being down must not pull an + otherwise-working replica out of the load balancer. + + Also lands a drift guard for **#3826**, and corrects ADR-0062's status while doing + it. The ADR claimed D1 ("exactly one definition → live driver path") as + implemented; only the _construction_ half converged. The `default` driver is still + registered as a `driver.*` kernel service and connected by `ObjectQLEngine.init()`, + with its own failure verdict, pool teardown, and no connect policy. What blocks the + merge is an input-shape mismatch, not ordering: `connect()` takes a datasource + _definition_ and builds the driver, while `default` arrives pre-built, and routing + it through the service would make `ObjectQLPlugin`'s boot depend on an optional + higher-layer service. Until that is designed, `degraded-boot-parity.test.ts` pins + both paths to the same operator-visible contract (fail-fast by default, identical + `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr) so a change + to one that forgets the other fails CI — #3741 → #3758 was exactly that miss, and + it cost three months and a second bug report. + + **Migration.** Additive. `DatasourceSummary.status` gains a `'blocked'` member: a + consumer exhaustively switching on it needs a case (the admin UI shows it as a + distinct state). Nothing that was `'ok'` or `'error'` changes meaning; rows that + were reported `'unvalidated'` now report their real state. Query-time errors for a + datasource the connection layer recorded change from a generic `Error` to + `DatasourceUnavailableError` (503 instead of the previous catch-all status); + matching on the old `is not registered` text still works for the undeclared-name + case, which is the only one that was ever accurate. + +- a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839) + + A grouped row whose dimension value is empty now carries `null` for that + dimension no matter which way the aggregate ran. Downstream code can test the + empty bucket with a plain `value == null` again: charts render their own empty + label, drill-through on that bucket builds `field = null` and returns the rows + it should, and a dashboard no longer changes shape when the driver, the + granularity or the reference timezone changes. + + ### What was wrong + + `engine.aggregate` has two implementations of one feature. It pushes the + aggregate down as SQL when the driver advertises every requested granularity and + the reference timezone is UTC; otherwise it fetches rows and buckets them in JS. + The two disagreed about how to spell "empty": + + ``` + --- same dataset, same query, one row with a NULL value --- + pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …] + in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …] + ``` + + The measures were always right — only the key's type and literal differed — + which is why this went unnoticed for so long: every total reconciled. But the + engine picks a path per query, so the same data produced a different bucket key + on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise), + a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of + which bucket in memory unconditionally. + + It was never date-specific either. A plain `groupBy: ['stage']` over a NULL + column diverged the same way. + + Consumers are written against `null` — they check `== null` and supply their own + empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated + every one of them: it rendered a raw English debug string in the UI, and a drill + on the empty bucket compiled to `field = '(null)'` and matched nothing. + + The in-memory path's comment justified the string as staying "consistent with + the client `useReportData` hook". That hook was removed with ADR-0021, and the + literal never appeared in it. + + ### What changed + + - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key + the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A + null instant and an unparseable one still share one bucket, because SQL cannot + tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL). + - The internal composite bucket id is JSON-encoded, so the empty bucket stays + distinct from a row whose value is the literal string `"null"`. + - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The + empty bucket has no calendar span, so a drill on it opens the unscoped + superset instead of an invented bound — unchanged behavior, honest signature. + - The driver output contract in `@objectstack/spec` now states the rule: a row + with no value keys as `null`, never a sentinel. Propagating NULL through the + bucket expression is the whole of it; a driver only breaks it by adding a + `COALESCE`. + + ### Gates + + `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null + instant, because the divergence would have failed it for a reason it was not + about. Its fixture now has one, so the convergence is held in place — including + for out-of-tree drivers that run the check against themselves. + + Two fixes were needed to make that fixture meaningful: + + - The check folded bucket labels through `String(value)`, which turns SQL NULL + into `'null'` — a label a TEXT column can genuinely hold. A driver spelling + "empty" as a string could compare equal to one returning real NULL. The empty + bucket is now keyed out of band. + - Label sets were compared with `JSON.stringify`, which is sensitive to key + insertion order. Row order is not part of this contract and the two paths + naturally differ (SQL sorts its groups; the in-memory path emits first-seen + order), so a driver with entirely correct buckets could be reported as + disagreeing — with an empty diff message, since nothing actually differed. + The comparison is now order-insensitive. + + A new dogfood check covers the non-date half against real drivers: same dataset, + plain and date-bucketed `groupBy`, both paths, one key. + +- 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582) + + Filter values travel as JSON, so a time- or user-scoped slice writes a + placeholder instead of code: + + ```ts + filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' } + ``` + + The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`, + `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it + (#3574). What was missing is the half that _substitutes a value_: **nothing on + the server ever did**. A placeholder reached the driver as the literal string + `'{current_year_start}'`, compared as text, and matched nothing. + + That failure is invisible — an empty widget looks exactly like a metric that is + legitimately zero — so apps worked around it by computing dates at module load, + which freezes "this year" into the built artifact and quietly goes stale. + + **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two + server-side seams every filter passes through: + + - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST + queries, related lists, saved-view filters and flow `find_records` all resolve. + It runs before the middleware chain, so only author-supplied filters are + inspected; RLS/sharing filters are injected downstream from concrete values. + - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's + `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s. + This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds + comparands directly, so a dashboard widget never passes through `engine.find()`. + + Behavioural notes: + + - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for + `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's + on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so + there is still exactly one source of truth for the storage convention. + - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned + per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair + can never straddle a boundary. + - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads + `userId`. A request carrying neither now **throws** instead of resolving to + `null` — a null comparand degrades to `IS NULL` on most drivers and would hand + back the rows the filter was written to exclude. + - An unrecognised placeholder **throws**, carrying the near-miss fix + (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` → + `{current_quarter_start}`). This matches what `objectstack build` already + enforces. Consequence, previously implicit and now load-bearing: a filter value + that is _entirely_ `{...}` is always read as a placeholder, so a literal value + of that shape is not expressible — rename the value. + + Also in this change: `notify` no longer sends the six-character string + `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks + `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)` + turned that into a phantom recipient — the emit "succeeded", addressed nobody, + and said nothing. Unresolved recipients are now dropped, and a node with no + recipient left fails naming the offending template and pointing at the start + node's `config.expand` (#3475), which does hydrate the relation. + +- de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810) + + Three related holes, all of which end in "the query matched rows the author + excluded". + + **1. A flow filter could silently widen to match everything.** + + The flow template interpolator expresses "this token did not resolve" as + `undefined`. In a message that renders as empty text — harmless. In a FILTER it + removes the condition, and a removed condition matches MORE rows. When it was + the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to + `deleteMany` is every row in the table. + + So one mistyped field name in a `delete_record` node silently emptied the + object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the + run never received, a lookup hop (`{record.account.name}` — the trigger record + carries a scalar id), and a filter placeholder. + + `get_record` / `update_record` / `delete_record` now refuse to execute when + interpolation erased any authored condition, naming the offending template. The + guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is + unaffected, and losing one of two conditions still fails, because widening from + "my open records" to "all open records" is the same class of bug. + + **2. Filter placeholders never reached the engine that resolves them.** + + `config.filter` is where two `{…}` dialects meet — the flow template dialect + (`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`, + `{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order + picked the winner by accident: the flow interpolator ran first, found no flow + variable by that name, and erased it. + + `interpolateFilter()` hands that position back to the dialect that owns it — a + whole-string token that no flow variable resolves and that IS a recognised + placeholder passes through verbatim for the engine to expand. Flow variables + keep precedence, so a template that works today cannot change meaning. + + **3. The engine resolved placeholders on reads but not on writes.** + + `resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So + the SAME filter selected different rows depending on the verb: `find({ owner: +'{current_user_id}' })` matched the signed-in user's rows, while + `update`/`delete` compared the literal token text and matched none — a flow that + previewed with one and acted with the other operated on two different row sets. + This is the #3106 shape one layer down: the evaluator existed, only some call + sites reached it. + + `update` and `delete` now resolve too, BEFORE the by-id fast path claims a + scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as + the primary key itself). Caller options are never mutated. + +- 5d4de37: fix(objectql,driver-sql)!: a group key is the column's value, in the shape `find()` presents it (#3849) + + `groupBy: ['qty']` now returns `3`, not `'3'`. `groupBy: ['won']` returns `true` / + `false`, not `'true'` / `'false'` on one path and `1` / `0` on the other. A bucket + key is a column value, so there is one right answer for what it looks like — + whatever that column looks like on a `find()` row — and all three paths that + produce one now give it. + + ### What was wrong + + Three code paths produce a group key, and no two of them agreed: + + | | `qty` (number) | `won` (boolean) | + | ------------------------- | ---------------- | ------------------------------- | + | `find()` | `3` number | `true` boolean | + | `aggregate()` pushed down | `3` number | `0` / `1` **number** | + | in-memory fallback | `'3'` **string** | `'false'` / `'true'` **string** | + + Two independent causes: + + - `applyInMemoryAggregation` ran every key through `String()`. The pushed-down + path never did. + - The pushed-down path returns raw builder output. #3797 taught it to present + temporal columns the way `formatOutput` does on a `find()` row, but not the + boolean and numeric repairs — so a SQLite boolean, which has no native type and + is stored as `0`/`1`, surfaced as an integer from `aggregate()` and as a real + boolean from `find()`. + + `engine.aggregate` chooses between the two aggregate paths per query — by whether + the driver aggregates natively, whether it advertises the requested granularity, + and whether the reference timezone is UTC — so the same column changed shape with + no change to the data or the query. + + ### Why it mattered + + The measures were always right, which is why this went unnoticed. What broke was + downstream code that probes a raw `Map` keyed by the value's own type. `Map` + lookup is SameValueZero, so `'1'` never finds `1`: + + - **Select-option labels** (`dimension-labels.ts`) — the label table is keyed by + the option's own `value`. A numeric option value never matched a stringified + key, so the chart rendered the raw stored value instead of its label. + - **Lookup / master-detail labels** — the id → record-name table is built by an + inner query that always pushes down (raw ids), then probed with the outer + query's keys, which may be in-memory (stringified). With a numeric primary key + — routine for external/federated objects — every label missed. + - **Cross-object rebucketing** (`cross-object-rebucket.ts`) — the FK → attribute + map is built and probed the same way, and a miss is not a fallback but + `RESTRICTED_BUCKET`. A numeric FK filed **every row** under `'(restricted)'`: + one bar, correct grand total, no error. + - **Drill-through** — the raw dimension value goes into the drill filter + verbatim, so a boolean dimension drilled from the in-memory path sent + `{ won: 'true' }` to SQLite, whose INTEGER column cannot equal the text + `'true'`. Zero rows. + + ### What changed + + - `applyInMemoryAggregation` (`@objectstack/objectql`) emits the value verbatim. + Its rows come straight from `driver.find()`, so passing the value through is + what makes the key equal the column's own read shape. + - The internal composite bucket id is now type-preserving, so `1` and `'1'`, + `true` and `'true'` stay distinct groups rather than merging on the way in. + BigInt is encoded explicitly — `JSON.stringify` throws on it, and a value that + used to bucket under `String()` must not start crashing the aggregate. + - `SqlDriver.aggregate` / `.distinct` (`@objectstack/driver-sql`) present group + keys and `min`/`max` results with the same rules `formatOutput` applies on a + `find()` row, generalizing the #3797 temporal fix to boolean and numeric + columns. The `protected` helpers behind it are renamed accordingly + (`temporalFieldKind` → `readPresentationKind`, `presentTemporalValue` → + `presentReadValue`, `presentTemporalColumns` → `presentReadColumns`) and the + kind union is exported as `ReadPresentationKind`. + + Date-bucketed `groupBy` items are unaffected: `bucketDateValue` and the dialect + bucket expressions both produce canonical string labels, and #3839 already pinned + their empty bucket. + + ### Gate + + `packages/qa/dogfood/test/group-key-read-shape-parity.test.ts` measures both + aggregate paths against `find()` for a number, boolean and text column, on + `driver-sql` and `driver-sqlite-wasm`. It asserts the runtime TYPE, not just the + value — folding both sides through `String()` is the reflex that hid this in the + first place and would make the check pass against the bug it exists to catch. + + Each half was confirmed to fail the gate on its own: reverting only the + in-memory change reddens the number and boolean cases, reverting only the driver + change reddens the boolean cases with `0` against `false`. + +- 030125b: feat(objectql)!: `init()` refuses to boot when a data driver fails to connect (#3741) + + `ObjectQLEngine.init()` wrapped every driver's `connect()` in a try/catch, logged + one error line, and carried on. A server whose database was unreachable therefore + "started successfully" — health endpoints could even stay green — and then failed + every request with an error that reads nothing like _the database is down_. The + warning it printed (`Operations may recover via lazy reconnection or fail at query +time`) was half fiction: grep the repo and no reconnection exists in `driver-sql` + or `driver-mongodb`, so only the "fail at query time" half was ever real. The + caller made it worse — `ObjectQLPlugin.start()` runs `syncRegisteredSchemas()` + immediately after `init()`, issuing DDL against a driver that isn't there. + + The structural half of the bug was worse than the operational one: the catch + removed a driver's ability to **refuse startup at all**. Any fatal startup check — + licence, server version, incompatible configuration, missing capability, not just + an unreachable socket — is expressed by throwing from `connect()`, and every one + of them was silently downgraded to a runtime error. That is why driver-mongodb's + multi-tenancy guard (#3724 / #3734) had to be hoisted into its constructor. + + - `init()` now **throws** `DriverConnectError` (`code: 'ERR_DRIVER_CONNECT'`) + when any boot-registered driver's `connect()` rejects, aborting kernel + bootstrap. It still attempts every driver first, so one failed boot names all + of them. The message is self-contained — each failed driver and its cause — + because the CLI prints `error.message` alone; the first cause is also attached + as `error.cause`. Exported from both `@objectstack/objectql` and + `@objectstack/objectql/core`. + - `connect()` is now a supported place for a driver to veto boot. Startup + validation that needs a live connection (server version, capability probes) + no longer has to be forced into a constructor. + - The misleading "lazy reconnection" warning is gone. + - New escape hatch `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` + (`resolveAllowDriverConnectFailure()` in `@objectstack/types`) restores the old + lenient boot, but loudly: a `DEGRADED BOOT` banner names the failed drivers and + states that they are never retried or reconnected and that every query and + schema sync routed to them will fail for the process lifetime. The banner goes + to stderr as well as the logger, because `os serve` swallows all of stdout + during boot and `Logger` routes `warn` there — logger-only, the one message + that matters would be invisible in exactly the deployment the flag is for. + Defaults off. + + **Migration.** No code or config change is needed for a correctly configured + deployment — a driver that connected before still connects. A deployment that was + _silently_ booting without its database now fails the boot instead, with the + driver name and cause in the error; fix the datasource configuration (typically + `OS_DATABASE_URL`, credentials, or network reachability). To keep booting without + it — deliberately, and knowing every request that touches it will fail — set + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +- 8e08bc3: feat(runtime): `/ready` reports 503 when a data driver stops answering (#3756) + + `/health` returned `{status: 'ok'}` unconditionally and `/ready` only checked + whether the kernel state was `running` — a flag set once when bootstrap finishes + and never revisited. Neither probe touched the data layer. So a database that + went away _after_ boot (restart, failover, network policy change, pool exhausted, + credentials rotated) left both probes green: the load balancer kept routing to a + replica that failed 100% of its requests, and the orchestrator saw nothing wrong. + The driver's `checkHealth()` already existed and was cheap (`SELECT 1` / + `db.command({ping:1})`) but was only consumed by `datasource-admin`'s + `testConnection` — no probe path called it, and `ObjectQL` exposed no way to ask + (`drivers` is private with no accessor). + + This is the runtime-side half of #3741, which fixed only the boot-time version + of the same defect. + + - New `ObjectQL.checkDriversHealth({ timeoutMs })` pings every registered driver + and returns a `DriverHealth[]` verdict. Each probe is settled independently and + bounded (default 2s) — `checkHealth()` swallows its own errors, but on a dead + knex pool it does not return at all, waiting out `acquireConnectionTimeout` + (60s by default), and a probe that hangs is as useless as one that lies. A + driver implementing no `checkHealth()` is reported healthy: absence of a probe + is not evidence of failure. + - `GET /ready` now returns 503 with the failing driver names when the kernel is + running but a driver is down, on top of the existing booting/shutting-down + cases. The result is memoized for ~1s so Kubernetes' few-second polling does + not become one database round-trip per probe per replica. + - `GET /health` deliberately still checks nothing, and now says why in the code. + A failing _liveness_ probe restarts the pod, which cannot fix an unreachable + database but would put every replica into a restart storm for the length of the + outage. Readiness — leave the rotation — is the failure mode that helps. + + The readiness check **fails open**: a kernel with no data engine (lite kernels, + edge, metadata-only hosts), an engine predating `checkDriversHealth`, or a probe + that itself throws all read as ready, exactly as before. Readiness gates whether + a replica receives any traffic at all, so an inconclusive answer must not + black-hole a working deployment. Only a driver that positively reports itself + unhealthy takes the replica out. + + **Migration.** None. Deployments already wiring `/api/v1/ready` as their + readiness probe get the stricter check automatically; deployments that pointed a + _liveness_ probe at `/ready` should move it to `/health`, which is the endpoint + that never fails on a dependency. + +- 20cb232: feat(metadata-protocol,objectql): MetadataProtocolPlugin + `registerProtocol` opt-out — ADR-0076 Step 2 PR-A (#2462) + + `createMetadataProtocolPlugin()` now owns what `ObjectQLPlugin` historically + assembled inline: the `ObjectStackProtocolImplementation` construction + + `protocol` registration, the metadata-storage platform objects, and the D12 + `degraded` analytics fallback (pattern: plugin-security — named plugin, + `dependencies` on the engine, `ctx.getService('objectql')`). `ObjectQLPlugin` + grows `registerProtocol?: boolean` (default `true`, fully backward + compatible): pass `false` when mounting the new plugin. Protocol CONSUMERS + stay on the engine plugin either way — DB hydration and the authored + hook/action rebind resolve `protocol` lazily (the rebind arms from `start()` + in delegated mode) and degrade gracefully. Mixing both assemblies fails fast + with the fix in the message. This is the additive first leg of the + cross-repo sequence; cloud's 3 boot sites flip in PR-B, the built-in + assembly + re-exports retire in PR-C. + +- e231abb: feat(objectql,metadata-protocol)!: single-source the protocol assembly; drop objectql's protocol re-exports — ADR-0076 Step 2 PR-C (#2462) + + The ONE assembly now lives in `@objectstack/metadata-protocol` as + `assembleMetadataProtocol()` — `createMetadataProtocolPlugin()` (delegated + mode, cloud) and `ObjectQLPlugin`'s built-in convenience mode + (`registerProtocol !== false`, single-kernel/dev boots) both mount the same + code path (~112 inline lines deleted from the engine plugin). objectql's six + protocol re-exports (`ObjectStackProtocolImplementation`, + `SysMetadataRepository`, `SeedLoaderService`, `runBuildProbes` + types) are + removed — import them from `@objectstack/metadata-protocol` directly + (breaking, shipped as minor per the launch-window convention; the only known + importers were five test files, repointed). Scope note vs the original Step-2 + recipe: the objectql→metadata-protocol dependency is deliberately KEPT for + the convenience mount — `@objectstack/objectql/core` was already + protocol-free, and forcing 20 framework boot sites to mount two plugins buys + no runtime win. "Zero protocol dependency" lands as "zero assembly ownership, + single source". + +- b95577a: feat(automation): surface silently-stripped write fields as step warnings (#3407) + + `update_record` used to report an unconditional `success` even when the data + layer legally stripped the requested write fields — static `readonly` (#2948) + or a TRUE `readonlyWhen` predicate (#3042). The only trace was a server-side + logger warn, invisible in the flow run trace: an author saw a clean 3ms + `success` while the DB truth never changed (how #3356's approval stage + write-backs failed unnoticed). + + - **spec**: new `DroppedFieldsEventSchema` / `DroppedFieldsEvent` + (`{ object, fields, reason: 'readonly' | 'readonly_when' }`) in + `data/data-engine.zod.ts`, and a `WriteObservabilityOptions` + (`onFieldsDropped` listener) mixin on `IDataEngine.insert/update` option + params in `contracts/data-engine.ts`. The listener is a TS-contract-level, + in-process-only channel — deliberately NOT part of the serializable Zod + options schemas or the RPC boundary. + - **objectql**: `engine.update()` reports each strip pass's dropped keys + + reason through `options.onFieldsDropped` (all four strip sites: single-id + + bulk × readonly + readonlyWhen). A throwing listener never breaks the write. + System-context writes skip the readonly strip and therefore report nothing, + as before. `insert()` accepts the option for symmetry but strips nothing + today (INSERT is readonly-exempt; FLS write denial throws). + - **service-automation**: `NodeExecutionResult` and `StepLogEntry` gain + advisory `warnings?: string[]`; `update_record` / `create_record` attach one + warning per strip event naming the dropped fields, plus a structured + `droppedFields` output (`{.droppedFields}`) for downstream nodes. + `success` semantics are unchanged — stripping stays legal, it just is no + longer silent. + +### Patch Changes + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- d44dbfa: feat(spec)!: shrink the `ApiMethod` enum to the six primitives — legacy values are stripped at parse, never honored (#3543, P2 of #3391) + + **BREAKING** (the `!` marker and this changeset are the breaking-change + record; the train ships as the v17 major — see the `v17-rc-anchor` changeset): + the authored `enable.apiMethods` enum is now exactly the six + primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`). The eight + legacy values (`upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, + `import`, `export`) are no longer authorable — they are DERIVED effective + operations, resolved by the server's single derivation table. + + **Migration (FROM → TO).** Replace each legacy value with the primitives it + derives from, then de-duplicate; if the result names all six primitives, delete + the `apiMethods` key entirely (equivalent to default-open, and it tracks future + primitives): + + | FROM (legacy) | TO (primitives) | why | + | ------------- | -------------------- | ---------------------------------------------- | + | `upsert` | `create`, `update` | upsert ⊆ create ∧ update | + | `import` | `create`, `update` | import ⊆ create ∨ update (writeMode-precise) | + | `export` | `list` | export ⊆ list | + | `aggregate` | `list` | aggregate ⊆ list | + | `search` | `list` | search ⊆ list ∧ `searchable` | + | `history` | `get` | history ⊆ get ∧ `trackHistory` | + | `restore` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + | `purge` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + + Reporter codemod: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` + (scans, reports the exact replacement per site, and flags whitelists the + mapping would WIDEN so the edit stays reviewable). + + **Stored metadata keeps parsing — permanent tolerance, narrowing only.** Real + metadata does not upgrade in lockstep with the spec, so a stored legacy value + is NOT a parse error: `stripLegacyApiMethods` (new export) strips it with a + FROM→TO warning (canonicalize-and-warn). Stripping only ever NARROWS exposure — + the derivation table still grants every legacy verb that derives from the + primitives you declared. Two cliffs to know: + + 1. A whitelist of ONLY legacy values (e.g. `['upsert']`) strips to `[]` = + **deny-all** — the object's API closes instead of widening. The strip + warning and the objectql registration diagnostic both call this out. + 2. A legacy value NOT derivable from your declared primitives (e.g. + `['get', 'export']` — export needs `list`) was honored by the P1 + "explicit wins" path and is now denied. Declare the underlying primitive. + + **Type split — authored vs effective vocabulary.** `ApiMethod` (authored) is + now six values; the NEW `ApiOperation` type / `ApiOperationSchema` / + `API_OPERATION_ORDER` (fourteen values, byte-stable pre-shrink wire order) + carry the EFFECTIVE vocabulary. The wire contract is unchanged: the 405 + `allowed` array and `/me/permissions` `apiOperations` still serialize derived + verbs (`export`, `search`, …), and `EffectiveObjectPermissionSchema.apiOperations` + now validates against `ApiOperationSchema`. `EffectiveApiMethods.explicitLegacy` + is removed (nothing is honored verbatim anymore); `API_METHOD_ORDER` remains as + a deprecated alias of `API_OPERATION_ORDER`. + + **Fail-closed tightening (#3545):** a PRESENT but non-array `apiMethods` (only + producible by a raw/out-of-band metadata write) now resolves to `deny-all` + instead of unrestricted — a policy that exists but cannot be read fails CLOSED. + + **Published JSON Schema diverges deliberately:** `data/ApiMethod.json` is the + strict six-value enum (a `z.preprocess` is not representable in JSON Schema), + so external JSON-Schema validators reject legacy values that the zod parse + would strip-and-warn. Treat the JSON Schema as the authored contract; the zod + tolerance exists for stored metadata. + + **objectql:** the P1 "explicit wins" transition is reclaimed — + `warnDeprecatedExplicitApiMethods` is replaced by `warnStrippedLegacyApiMethods` + (a permanent per-object diagnostic for schemas that reach the registry without + passing through Zod; the parse-time strip warning carries no object name). + + **platform-objects:** whitelist audit — `sys_business_unit`, + `sys_business_unit_member` (P1's explicit `import`/`export` reclaimed) and + `sys_user_preference` dropped their `apiMethods` entirely (each named all six + primitives = default-open). Read-only and deny-all whitelists are unchanged; + the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside + `apiEnabled: false`. + +- b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) + + The record lock is keyed on a **pending** `sys_approval_request`, and it could + not tell _the run that owns that request_ from _an unrelated user editing the + record_. So a flow that touched its own target record while its own approval was + still pending — a manual `resume` with no decision, or a node that writes the + record between opening the approval and the decision — died on its own + `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery + existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it + self-healing. + + Both halves are now closed. + + **Prevention — the owning run may write its own record.** The automation engine + stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it + travels with every data node's ObjectQL context into `ctx.provenance`. The lock + hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`. + It is keyed on run identity rather than elevation on purpose: a `runAs:'user'` + run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance — + server-constructed like `isSystem`, never client-supplied, evaluated by no + security middleware, and the only write it permits is to the one record its own + run already holds a pending request against. + + **Recovery — a sweep releases records held by runs that died anyway.** A pending + request whose owning run has reached a terminal state (`completed`, `failed`, + `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled` + — releasing the lock — and audited under the reserved actor `system:dead-run` + with the run and its status in the comment, so it is never mistaken for a + submitter's withdrawal. It runs on the existing approvals sweep clock, which also + covers the case no in-band handler can: a run killed by a process crash. + + The sweep is fail-safe by construction. It acts only on an explicit terminal + status from a closed set; `paused` (the normal state of a live approval), + `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a + deployment with no automation engine are all read as "still alive". The failure + mode is "a dead run's lock survives until an admin recalls it" — today's + behaviour — never "a live approval is destroyed". + + Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for + a run id rather than the latest. A run that pauses and later finishes records two + entries under one id, so every suspend-then-finish run — every approval, screen + and wait flow — reported itself as `paused` forever, both on the Runs + observability surface and to this sweep. + + One shape was left out here and closed separately in #3712: a `runAs:'user'` run + with no trigger user (a schedule) resolved no ObjectQL context at all, so it + carried no `flowRunId` and stayed subject to the lock. It now passes a + provenance-only context — the run id and nothing the security middleware keys on + — so it is attributable without acquiring a principal, and its documented + unscoped posture (#1888) is unchanged. + +- c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712) + + #3456 let the run that opened a pending approval write its own target record, + keyed on `flowRunId`. It worked for every run that resolves an identity and + missed the one that doesn't: an effective `runAs:'user'` run with **no trigger + user** — a schedule being the canonical case — passed no ObjectQL context at + all, so nothing carried the run id and the run still died on its own + `RECORD_LOCKED`. + + The blocker was never the lock. It was that "no identity" and "no context" were + the same thing on the wire, so a run could not say _who it was_ without also + claiming _what it was allowed to do_. + + **A run with no principal now passes provenance alone.** + `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`, + no `permissions`, not even `isSystem: false`. Every principal gate keys on one + of those fields (the elevation short-circuit on `isSystem`, the ADR-0103 + engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`, + the empty-principal fall-open on all three), so this context authorizes + **identically to no context at all**. The run keeps the documented #1888 + unscoped posture, its loud `[runAs]` warning, and the + `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch + changed — only that it can now be attributed. + + **Provenance moved out of the hook session, into `ctx.provenance`.** `session` + answers _who is calling_ and is absent when no identity envelope was supplied — + a distinction real gates depend on (the attachment access gate skips bare-kernel + writes on exactly that test). Folding a run id into `session` would have forced + an identity-less run to present an empty session, silently turning "no caller" + into "an anonymous caller" and narrowing the #1888 fail-open for attachments + alone. `HookContext.provenance.flowRunId` says what produced the write; the + approvals lock reads it there. + + Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope + (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time + defaults, which made them _required_ on a caller-supplied option and asserted + something untrue — that every data-engine context carries a principal. Callers + have always passed slices (`{ isSystem: true }` for a system read); the type now + says so. + + Migration: nothing to change unless you read the run id inside a hook. If you + wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the + field never shipped under the old name. + +- 87aca93: fix(datasource)!: a declared datasource that objects bind to must connect, or the boot fails (#3758) + + `DatasourceConnectionService.handleFailure()` fail-fasted only for an `external` + datasource with `validation.onMismatch: 'fail'`. Everything else degraded to one + `warn` line — including the case the D2 auto-connect gate itself flags as having + **no fallback path**: a datasource that objects bind to explicitly via + `object.datasource`. Those objects never fall through to the `default` driver; + `engine.getDriver` throws `Datasource 'x' is not registered` for them. + + So an app declaring `datasource: 'analytics'` with 20 objects bound to it, booted + against a wrong `ANALYTICS_URL`, started clean and exited zero — and then failed + every read and write of those 20 objects with an error that reads nothing like + _the analytics database is unreachable_. The rest of the app worked, which made it + **harder** to locate than a total outage: it looks like "some pages are broken", + not like a misconfigured datasource. This is the same decision #3741/#3751 fixed + one layer up in `ObjectQLEngine.init()`; the boundary here was still drawn in the + old place. + + - **Fail-fast is now keyed on "no fallback path", not on `onMismatch` alone.** At + the `declared-auto` (boot) trigger, a connect failure aborts the boot when the + datasource is `external` + `onMismatch: 'fail'` **or** when ≥1 object binds to + it explicitly. `autoConnect: true` with nothing bound stays lenient — that is + "connect it if you can", and nothing declares a dependency on it. The + runtime-admin create/update and boot-rehydration triggers are unchanged and + still always degrade: a UI action must never brick a running server. + - **Every failure mode counts**, not just an unreachable socket: an unresolvable + `external.credentialsRef` (D3) and an unsupported `driver` leave the bound + objects exactly as dead, so they take the same verdict. + - **The error names the bound objects** (up to 10, then `+N more`) alongside the + underlying cause, so the message points at the real problem instead of just the + datasource name. The service already receives the list for post-connect + `syncObjectSchema`. + - **`connectDeclared()` attempts every gated datasource before throwing**, and + aggregates, so one failed boot reports all the misconfigured ones rather than + one per restart — the same shape as `ObjectQLEngine.init()`'s + `DriverConnectError`. + - **The escape hatch is shared with the engine guard**: + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` now also covers this path (and covers + `onMismatch: 'fail'`, which previously had no opt-out). The operator intent is + identical — "I know the database is unreachable, boot anyway" — and two flags + would only guarantee one of them gets missed. When set, boot continues and a + `DEGRADED BOOT` banner goes to stderr as well as the logger, because `os serve` + swallows stdout during boot. `emitDegradedBootBanner` moved to + `@objectstack/types` so both call sites share one implementation; + `@objectstack/objectql` re-exports it unchanged. + + ADR-0062 D5 is amended with the new criterion and the shared flag. + + **Migration.** No change for a correctly configured deployment — a datasource that + connected before still connects. A deployment that was _silently_ booting with a + dead, explicitly-bound datasource now fails the boot instead, naming the + datasource, the cause, and the objects that depend on it; fix the datasource + configuration. To keep booting without it — deliberately, knowing every request + touching those objects will fail — set `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +- 32d3800: fix(driver-sql): bound a connection attempt at 10s, and correct the "no reconnection" claim (#3769, #3759) + + Two related corrections, both from measuring what #3741/#3751/#3765 had only asserted. + + **The claim was wrong.** #3751 and #3765 shipped several statements that drivers + never reconnect — "there is no lazy reconnection", "NOT retried and NOT + reconnected", "stays disconnected for the process lifetime". Measured, both + drivers recover on their own: + + - driver-mongodb: killing a real `mongod` and restarting it on the same port, + the _same_ driver instance served the next write successfully (13ms), with no + reconnect call from us — the official driver's topology monitor handles it. + - driver-sql: a knex/pg pool is not poisoned by an outage. Its error tracks live + server state (`ECONNREFUSED` while down → a handshake error once a listener is + back → `ECONNREFUSED` again), i.e. every acquire opens a fresh connection. + `storage-driver.ts` also configures `pool.min: 0`, so no stale idle + connections are held. + + The original reasoning grepped this repo for `reconnect`, found nothing, and + concluded recovery does not happen — but the recovery lives in the client + libraries, not in our code. The claims are now corrected in `DriverConnectError`, + the `DEGRADED BOOT` banner, `resolveAllowDriverConnectFailure`'s docs, and the + drivers / self-hosting pages. + + **Fail-fast at boot is unchanged and still correct** — the reason is just + different. It is not that the connection can never return; it is that the _boot + sequence_ never re-runs. A driver that missed `init()` also missed + `syncRegisteredSchemas()`, so its tables can simply not exist even after the + database comes back. The banner now says that. + + **The real defect underneath.** `SqlDriver` passed its config to knex untouched, + so a database endpoint that accepts TCP but never completes the handshake — an + overloaded instance, a half-open firewall, a load balancer mid-failover — made + every query wait out tarn's 30s default, then fail with `Timeout acquiring a +connection. The pool is probably full`, pointing an operator at pool sizing + instead of the network. With a small `pool.max` a few such queries saturate the + pool and everything else queues. + + `SqlDriver` now defaults `pool.createTimeoutMillis` to **10s**, matching + driver-mongodb's existing `connectTimeoutMS ?? 10_000` so both drivers give up on + an unreachable server at the same point. A host that sets its own + `createTimeoutMillis` is left alone. + + **Migration.** None for a healthy datasource. A deployment that deliberately + relies on connection establishment taking longer than 10s (a slow cross-region + replica) should set `pool.createTimeoutMillis` explicitly on its `SqlDriver` + config. + + Not fixed here, tracked in #3769: knex still reports the bounded wait as "the + pool is probably full". An accurate message needs a dialect-specific connect + timeout (pg's `connectionTimeoutMillis`), which changes the shape of `connection` + and would regress the startup banner's URL display. + +- 0e3a226: fix(authz): widen the driver's native tenant scope to the membership union + under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623) + + The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids` + under `group`, but the ObjectQL engine also propagated the active-org + `tenantId` into `DriverOptions` unconditionally, and the SQL driver's native + scoping ANDed `organization_id = tenantId` under the union — collapsing every + group read back to active-org (isolated) reach. Found by the cloud-side + `ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group` + against a real driver. + + - `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with + native scoping widen reads/updates/deletes/aggregates to `IN (...)`, + keeping the NULL-tenant global-row carve-out; inserts still stamp from + `tenantId` (the active organization is the write target, D5). Absent or + empty ⇒ equality fallback — fail toward isolation, never toward exposure. + - ObjectQL engine threads `ExecutionContext.accessible_org_ids` as + `tenantIds` when the tenancy posture is `group`, reported by a new + `setTenancyPostureProvider` seam. + - SecurityPlugin wires that provider at start — deliberately from the + enforcement layer, so the driver wall only widens while the Layer 0 union + wall enforces above it. Embeddings without plugin-security keep active-org + equality. + +- 81ce41a: feat(rest): `treatAsHistorical` import also preserves the original audit timeline (#3493) + + Follow-up to #3479/#3483. `treatAsHistorical` solved the FSM half — mid-lifecycle + rows are no longer rejected by `initialStates` — but the OTHER half of a historical + migration, preserving the original timeline, still didn't hold: an imported ticket + that closed in 2021 stored `updated_at` = the import day (and `updated_by` = the + importer), and a `writeMode: 'upsert'` refresh silently dropped business `readonly` + fields (`closed_at`, `resolved_by`). Reports, audit, and "recently modified" + sorting all came out wrong. + + Three layers were force-overwriting the timeline; all three now respect a single + new opt-in flag, `ExecutionContext.preserveAudit`, which `treatAsHistorical` sets + alongside `skipStateMachine`: + + - **spec**: `ExecutionContext.preserveAudit` (server-set only, never client-supplied) + and `DriverOptions.preserveAudit` (threaded to the driver's update stamp). + - **objectql** — the built-in audit hook (`plugin.ts`) now treats `updated_at` / + `updated_by` as CLIENT-PREFERRED (`?? now` / `?? userId`) under `preserveAudit`, + symmetric with how `created_at` / `created_by` already behave on insert; and the + static-`readonly` write strip (`stripReadonlyFields`) admits a WHITELIST — the + audit/timestamp family plus author-declared business `readonly` fields — so an + upsert refresh no longer drops them. + - **driver-sql** — the SQL `update` path keeps a supplied `updated_at` instead of + force-advancing it to `now` when `DriverOptions.preserveAudit` is set (fills-only- + empty, mirroring the insert stamp). + - **rest** — the import runner sets `preserveAudit` on the write context iff the + request opts into `treatAsHistorical`. + + Deliberately a WHITELIST, not the blanket `isSystem` exemption: platform-managed + `system` columns OUTSIDE the audit family (`organization_id` / tenancy, generated + columns) STAY stripped, so a historical import reinstates established facts without + becoming a backdoor to forge tenancy. Permissions / RLS / field-level security are + unaffected — this changes only which audit/readonly values the runtime overwrites, + never who may write the record. Fully opt-in: a normal write still auto-stamps + `updated_at`/`updated_by` and strips `readonly` exactly as before. The objectui + "Import as historical data" checkbox (objectui#2815) now drives both halves — no new + UI. + +- 85e1e4e: feat(rest): `treatAsHistorical` import option — skip the state machine for historical-data migration (#3479) + + Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced + the FSM entry point on every INSERT, so importing established historical facts — + a batch of already-`closed` tickets, `closed_won` deals, `completed` projects — + was rejected row-by-row with `invalid_initial_state`, blocking the core + data-migration path. Unlike the seed case it was visible (per-row errors), but it + still functionally blocked a legitimate use. + + - **spec**: `ExecutionContext.skipStateMachine` — a general, server-set flag (the + seed-specific `seedReplay`'s sibling) that skips the `state_machine` rule for a + write; `ImportRequestSchema.treatAsHistorical` (default `false`) — the user-facing + import option. + - **objectql**: the engine now skips the state machine for `seedReplay` OR + `skipStateMachine` (one helper), covering both seed replay and historical import. + - **rest**: the import runner sets `skipStateMachine` on the write context iff the + request opts into `treatAsHistorical`; default off, so a normal import still walks + the FSM (the strict behavior is the default). Import **undo** now also carries + `skipStateMachine`, since restoring a prior snapshot re-writes an earlier state + that need not be a legal transition from where the row is now. + - **platform-objects**: `sys_import_job.treat_as_historical` audit column (additive). + + Scope is identical to the seed exemption: ONLY the `state_machine` rule is skipped; + field shape, `format`, `cross_field`, `script` all still run. The objectui import + wizard checkbox is a separate follow-up. + +- e1fa8d5: fix(objectql): arm the late-manifest metadata bridge on project kernels too + + The per-manifest bridge added for marketplace installs (#3428) armed itself + inside the same `environmentId === undefined` gate as the one-shot startup + bridge — but `os dev` boots the kernel project-scoped (environmentId + 'env_local'), which is marketplace install-local's primary home, so the fix + was inert exactly where it matters. Caught by browser-dogfooding the install + flow. + + The gate is correct for the one-shot bridge (it copies the entire + process-wide SchemaRegistry, which would leak sibling-project objects on + multi-environment servers) but does not apply to the per-manifest bridge: it + only copies the objects of the one package this kernel just registered. + Arming now happens unconditionally at the end of `start()`; boot-time + behavior on every kernel shape is unchanged (the flag still flips only after + the startup path has run), and the one-shot bridge keeps its gate. + +- 402f534: fix(objectql): bridge late-registered manifest objects into the metadata service + + Marketplace-installed template packages register through the `manifest` + service on `kernel:ready` (install) or later (HTTP install), but the one-shot + SchemaRegistry→metadata bridge runs once during `ObjectQLPlugin.start()` — + so their objects only ever reached the ObjectQL registry. Every + IMetadataService consumer (AI `describe_object`, Studio object lists, + `metadata.listObjects`) missed them; only the seed loader had grown an + engine-side fallback (#3422). + + The manifest service's `register` now bridges the manifest's own objects into + the metadata service after registering them with the engine, resolving the + service at call time and mirroring the startup bridge's contract: + `register('object', name, obj, { notify: false })` (#3112), skip entries it + did not bridge itself, refresh its own copy on same-package re-install (hot + upgrade). Armed only after `start()` has run the one-shot bridge, and never + on project kernels — boot-time behavior is unchanged. `register` now returns + a promise; the marketplace install/rehydrate paths await it so metadata reads + right after an install are deterministic. + +- 0c302a7: Exempt curated seed writes from `state_machine` validation (#3433). + + A seed is a snapshot of established facts — a project already `completed`, an + opportunity already `closed_won` — not a record walking its lifecycle. But once + an object declared `state_machine.initialStates` (#3165), the write path enforced + the FSM entry point on **every** insert, so seed replay silently rejected every + mid-lifecycle row and cascaded its master-detail children. That is the "installed + but no data" failure for the showcase board (1 of 5 projects), and it would hit + every marketplace template (a `closed_won` opportunity, a `closed` case) plus the + rehydrate-heal and per-org replay paths. + + `SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay` + flag; the engine passes `skipStateMachine` to the rule evaluator for those writes, + which skips the `state_machine` rule on both insert (`initialStates`) and update + (transitions). The exemption is scoped to `state_machine` only — a seed must still + satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`, + `conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`, + the fix covers boot inline seed, marketplace install/heal, and per-org replay at once. + + The showcase project seed drops its three-phase FSM-walk workaround (#3415) and + seeds each project directly at its real status again. + +- 5f0852f: fix(driver-sql): bucket a SQLite `Field.datetime` by its stored instant instead of collapsing every row into one `(null)` (#3773) + + On SQLite, any trend chart bucketed by day/week/month/year over a + `Field.datetime` column put **every record in a single `(null)` bucket** — one + bar, carrying the whole total. The measure was right; only the bucket key was + wrong. `Field.date` (ISO TEXT storage) was unaffected, so the same dashboard + could show one column working and the next one flat. + + better-sqlite3 stores a `Field.datetime` as INTEGER epoch **milliseconds** (knex + binds a JS `Date` as `.getTime()`), and `buildDateBucketExpr` emitted a flat + `strftime('%Y-%m', col)`. SQLite reads a bare integer as a **Julian day + number**; an epoch-ms value is far outside the legal range, so `strftime` + returned NULL for every row. Nothing downstream noticed: SQLite advertises + `queryDateGranularity.month`, so `engine.aggregate` pushes the bucketing down, + and its in-memory fallback only engages for an _unsupported_ granularity or a + non-UTC timezone. + + The SQLite expression is now storage-aware, sharing one `isEpochStoredDatetime` + predicate with the filter-comparand coercion added for the same root cause in + \#2034 — a window and a bucket that disagree about storage is exactly how an + epoch column ended up correctly filtered and then entirely bucketed as NULL. + Postgres and MySQL are untouched: `defineColumn` maps `Field.datetime` to a + native timestamp there, which is also why their comparands are left alone. + + Two details are load-bearing and pinned by tests: + + - The conversion dispatches on each **stored value's** type, not just the + declared one. A SQLite `Field.datetime` column is genuinely mixed-form — + `formatInput` passes datetime values through, so a `Date` lands as INTEGER + while an ISO string (including an unresolved `defaultValue: 'NOW()'`) lands as + TEXT. Dividing TEXT by 1000 coerces it to its leading year, filing live rows + under 1970 — worse than the NULL it replaced. + - Division is `/1000.0`, not `/1000`. Integer division truncates toward zero, so + a pre-1970 instant (`-1` ms) would surface as 1970-01-01. + + `bucketDateValue` (the in-memory fallback in `@objectstack/objectql`) now reads a + finite **number** as epoch milliseconds. `new Date(String(1767225600000))` is an + Invalid Date, so a driver handing back raw storage values bucketed as `'(null)'` + there while the pushed-down SQL bucketed correctly — fixing only the driver would + have traded one wrong answer for two different ones, and the two paths have to + label the same instant identically for a drill-down to survive crossing them. + + `SqliteWasmDriver` inherits `buildDateBucketExpr`, so it carried the bug and gets + the fix. + +- cde1975: fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) + + `os dev` on the stock showcase printed three fixed noise sources on every boot, + with zero example-side changes — training users to ignore warnings. + + - **spec** — add a field-level `ackPlaintextMasking: true` opt-out for the + generic `password` author-time warning (ADR-0100). A deliberately-masked + field (like field-zoo's `f_password`) can now affirm intent instead of + printing an un-actionable "safe to ignore" on every boot; the warning text + points authors at the flag. + - **plugin-auth** — pass better-auth's documented + `silenceWarnings.oauthAuthServerConfig` to `oauthProvider(...)`. We already + mount the `/.well-known/oauth-authorization-server` documents ourselves at + the issuer root, so the plugin's "please ensure it exists" reminder was a + false positive (printed twice); silencing it removes both. + - **objectql** — route the Registry's re-register / package-overwrite lines + (normal rebuild / HMR / seed-replay paths) through a new debug-only + `SchemaRegistry.debug()` so they stay out of the default `info` boot log. Adds + a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so + the debug-gated housekeeping is discoverable for troubleshooting. + +- 54f479a: fix(objectql): accept relative and inline URLs on `url` fields + + The record-validator's `url`-type check required an absolute `scheme://` URL, + so it rejected the **root-relative** value the platform's own storage service + returns for an uploaded file. The console avatar uploader + (`createObjectStackUploadAdapter`) PUTs the image to storage and then writes + `sys_user.image` (a `Field.url`) = `/api/v1/storage/files/`; that failed + `invalid_url` and — on the better-auth `update-user` path — surfaced as a + failed profile save (the "上传用户头像报错" avatar-upload bug). + + `URL_RE` now also accepts root-/protocol-relative refs (`/path`, `//host/path`) + and the `data:` / `blob:` inline forms, in addition to `scheme://…`. A bare + scheme-less string with no leading `/` (e.g. `"notaurl"`) is still rejected. + Verified end-to-end in the running Console: avatar upload → display → replace → + remove all succeed. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [4c5a584] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/metadata-protocol@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 49cf538d43..24c591a9ee 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "16.1.0", + "version": "17.0.0-rc.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 d9ef79cf5e..0e7d3c34cd 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/observability +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 13023adb78..3f0435e626 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "16.1.0", + "version": "17.0.0-rc.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 b4ef07c41e..4ae761086a 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,945 @@ # @objectstack/platform-objects +## 17.0.0-rc.0 + +### Major Changes + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +### Minor Changes + +- fdb4f50: feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617) + + The ADR-0104 file-as-reference migration ships as a command a deployment runs + against its own database, and the deployment-level flag it records is what may + later authorise irreversible behaviour — never the platform version. + + ```bash + os migrate files-to-references # dry run: reports, writes nothing + os migrate files-to-references --apply # converts, verifies, records the flag + ``` + + The run backfills legacy file-field values (inline metadata blobs, own-resolver + URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership + ledger against what records actually hold, and — only on an `--apply` run whose + reconciliation reports **zero blocking discrepancies** — records + `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. + + **Why a flag rather than a release note.** ObjectStack is a development + platform: third-party deployments upgrade on their own schedule and their data + is not observable by anyone else, so no release-side soak can vouch for them. + The evidence has to be produced where the data is. Consequences: + + - Installing a new version never starts deleting bytes. Running the migration + and passing its self-check is the consent. + - Not run, or not passed → files are retained forever. Wasted storage, zero + data loss. + - A later failing run **clears** `verified_at`: a deployment whose data has + drifted closes its own gate. + - A dry run writes nothing at all — not the conversions, and not the flag, + even when the self-check would pass. + - External URLs stay advisory. They are not `sys_file`s, so they can never + enter collection; whether to remodel them as a `url` field is the app + author's decision (ADR-0104 R7), not a gate. + + Ships alongside: + + - `@objectstack/spec` — `DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`, + and the single `isDataMigrationFlagVerified` predicate both future consumers + (collection #3459, strict value-shape #3438) read, so the two gates cannot + disagree about the same fact. + - `@objectstack/platform-objects` — the `sys_migration` object plus + `readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`. + Reads fail toward "not verified": a gate that cannot read its evidence stays + closed. + - `@objectstack/objectql` — a read may now opt out of file-reference expansion + via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's + bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to + their expanded form before the reconciliation sees them, which reports held + references as absent — noisy `stale_owner` findings, and a missed + `unowned_reference` would have been a false pass of the collection gate. + +- 1bd5652: feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the + `delegated_admin` org role, capped so it cannot mint authority (#3697) + + D8 authorizes invitation _placement_ against the issuer's `adminScope` + (ADR-0090 D12), so a delegated plant admin may invite only into their own + subtree. That gate is implemented, unit-proven and reachable — but no principal + could reach it in a state where it did anything: + + - better-auth grants `invitation: ["create"]` to `owner` and `admin` only + (`memberAc` holds `invitation: []`, which every other registered role + inherits); + - under a wall-enforcing posture, owners and admins are auto-elevated to + `organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard + `modifyAllRecords` that makes `isTenantAdmin()` true — and the gate + short-circuits on tenant admins. + + The two sets were disjoint. Issuance placement was bounded by the Layer 0 org + wall (real, and correct) but never by `adminScope`, so D8's motivating story — + "a plant admin invites into their own subtree without a platform admin + finishing the job" — could not happen. + + **Two pieces, and they only ship together.** + + **1. The role.** `delegated_admin` is now registered with the organization + plugin as `memberAc.statements` plus `invitation: ["create"]` — the one + membership grade that may reach `/organization/invite-member` without being an + org admin. Deliberately _not_ `invitation: ["cancel"]`: better-auth's cancel + route checks the permission with no inviterId attribution, so it would mean + "cancel anyone's pending invitation in the org". + + The role carries no ObjectStack authority by construction — `mapMembershipRole` + passes it through as a position name, and with no `sys_position_permission_set` + binding that name resolves to nothing. Role = _can reach the endpoint_; + `adminScope` = _what the endpoint permits_. + + `sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a + fourth option. Those selects are **enforced on write** — better-auth's own + invitation and membership inserts are validated like any other row — so + registering the role with the org plugin without listing it in both would have + produced a role nobody could hold and nobody could hand out + (`ValidationError: role must be one of: owner, admin, member`). That is exactly + how the end-to-end regression caught it, twice; neither unit test could. The + three non-English translation bundles carry the English label for the new option + until localized. + + **2. The role cap**, in the framework's own `beforeCreateInvitation` hook, + beside the D8 placement gate. Registering the role alone would have been a + four-step privilege escalation: better-auth's only role-level cap on _what role + you may invite someone as_ is its `creatorRole` check (default `owner`), which + blocks inviting an **owner** but not an **admin** — and an accepted `admin` + membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A + subtree-scoped delegate could have manufactured a tenant admin, with every + existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the + acceptance-time membership write runs under better-auth's context, not the + issuer's). + + The cap refuses an invitation whose role outranks the issuer's own, and + restricts a below-admin issuer to plain `member` — not merely "not admin/owner", + because an app-registered role projects into `current_user.positions` and may be + bound to permission sets, making it a capability channel too. A delegate's + channel for capability is the invitation's _placement_ intent, which the D12 + gate allowlists position-by-position. The cap applies to every invitation, + placement-carrying or not (the escalation is independent of placement), and + fails closed: an issuer role that cannot be resolved confers nothing above a + plain member. + + **What changes for deployments.** One new class of principal exists: members + holding the `delegated_admin` org role, who can invite into the org — as + `member` only, into the subtree their `adminScope` allows. It is opt-in twice + over (someone must set the membership role _and_ grant an adminScope set), so a + default deployment changes not at all. Org owners and admins are unaffected. + + Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so + console and control-plane surfaces name the role from one place. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- 67452d1: feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589) + + Custom system pages authored as metadata (Installed Apps, Cloud Connection, + Connect an Agent) hard-code their `page:header` copy in + `properties.title` / `properties.subtitle`. Every other metadata type is + localized at the REST boundary, but `page` was not: the `pages` namespace + existed only on `AppTranslationBundleSchema` — a schema no runtime reads — + with no resolver behind it, so those headers stayed English in every locale + while the matching nav labels translated correctly. + + - `TranslationDataSchema` (the shape the i18n service actually serves) gains a + `pages` namespace: `pages..{label,description,title,subtitle}`. + - New `translatePage` in `@objectstack/spec/system` translates a page's own + `label` / `description` and overlays `title` / `subtitle` onto every + `page:header` in the page's regions. Registered in + `translateMetadataDocument`, so it rides the existing read path. + - `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale + extraction, the locale-keyed ETag, and `Vary: Accept-Language` already + covered every metadata type — no new plumbing. + - `objectstack i18n extract` now emits page entries, including the + `page:header` copy, so the new namespace is not invisible to the tooling. + - zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus + the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these + existed only in zh-CN). + + Header copy is keyed by **page name**, not by component id: `page:header` + instances carry no stable id. `title` falls back to `pages..label`, since + a page's header title and its nav label are normally the same string. + + Authoring is unchanged and English literals stay in metadata as the fallback — + a page with no `pages` entry renders exactly as before. Consumers of + `@object-ui` need no change: pages arrive already localized from the server. + +- aa8b847: feat(authz): scoped invitations — placement intent on an invitation, gated by + the issuer's adminScope and applied on acceptance (ADR-0105 D8) + + An invitation may now carry PLACEMENT INTENT — the business unit the invitee + lands in and the positions they are assigned — so a delegated (plant) admin's + invitee arrives already in the right unit and role instead of waiting on a + platform admin. This closes the structural gap ADR-0105 D8 names for + `single`-posture deployments and is the natural admission path under `group`. + + The two halves ship together, deliberately: + + - **Issuance is authorized** against the ISSUER's `adminScope` (ADR-0090 D12), + by dry-running the existing `DelegatedAdminGate` against the very + `sys_user_position` rows the acceptance would write. The gate is reused + verbatim — no second copy of the subtree/allowlist logic to drift — so an + invitation can never place what its issuer could not have assigned directly. + Without that gate the feature would be an escalation hole: the built-in + `organization_admin` is deliberately read-only on the RBAC tables precisely + so a fresh org admin cannot rebind themselves, and applying an unchecked + invitation payload under system context would hand that authority straight + back. + - **Acceptance applies it**, idempotently and failure-isolated: a replayed + acceptance converges instead of duplicating assignments, and a placement + miss never undoes a valid membership. + + Surface: + + - `sys_invitation` gains `business_unit_id` + `positions` (ADR-0092 extension + fields, registered in the D7 collision-guarded whitelist; NOT generically + editable — placement is set only at issuance, through the gate). + - `@objectstack/plugin-security` registers the `invitation-placement` service + (`assertIssuable` / `apply`). + - `@objectstack/plugin-auth` wires better-auth's `beforeCreateInvitation` / + `afterAcceptInvitation` to it. **Fail closed**: an invitation that requests + placement in a deployment without the delegated-administration runtime is + refused, never silently placed unchecked. + + Existing invitations are unaffected — an invitation without placement intent + never consults the gate and behaves exactly as before. + +### Patch Changes + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- d44dbfa: feat(spec)!: shrink the `ApiMethod` enum to the six primitives — legacy values are stripped at parse, never honored (#3543, P2 of #3391) + + **BREAKING** (the `!` marker and this changeset are the breaking-change + record; the train ships as the v17 major — see the `v17-rc-anchor` changeset): + the authored `enable.apiMethods` enum is now exactly the six + primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`). The eight + legacy values (`upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, + `import`, `export`) are no longer authorable — they are DERIVED effective + operations, resolved by the server's single derivation table. + + **Migration (FROM → TO).** Replace each legacy value with the primitives it + derives from, then de-duplicate; if the result names all six primitives, delete + the `apiMethods` key entirely (equivalent to default-open, and it tracks future + primitives): + + | FROM (legacy) | TO (primitives) | why | + | ------------- | -------------------- | ---------------------------------------------- | + | `upsert` | `create`, `update` | upsert ⊆ create ∧ update | + | `import` | `create`, `update` | import ⊆ create ∨ update (writeMode-precise) | + | `export` | `list` | export ⊆ list | + | `aggregate` | `list` | aggregate ⊆ list | + | `search` | `list` | search ⊆ list ∧ `searchable` | + | `history` | `get` | history ⊆ get ∧ `trackHistory` | + | `restore` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + | `purge` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + + Reporter codemod: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` + (scans, reports the exact replacement per site, and flags whitelists the + mapping would WIDEN so the edit stays reviewable). + + **Stored metadata keeps parsing — permanent tolerance, narrowing only.** Real + metadata does not upgrade in lockstep with the spec, so a stored legacy value + is NOT a parse error: `stripLegacyApiMethods` (new export) strips it with a + FROM→TO warning (canonicalize-and-warn). Stripping only ever NARROWS exposure — + the derivation table still grants every legacy verb that derives from the + primitives you declared. Two cliffs to know: + + 1. A whitelist of ONLY legacy values (e.g. `['upsert']`) strips to `[]` = + **deny-all** — the object's API closes instead of widening. The strip + warning and the objectql registration diagnostic both call this out. + 2. A legacy value NOT derivable from your declared primitives (e.g. + `['get', 'export']` — export needs `list`) was honored by the P1 + "explicit wins" path and is now denied. Declare the underlying primitive. + + **Type split — authored vs effective vocabulary.** `ApiMethod` (authored) is + now six values; the NEW `ApiOperation` type / `ApiOperationSchema` / + `API_OPERATION_ORDER` (fourteen values, byte-stable pre-shrink wire order) + carry the EFFECTIVE vocabulary. The wire contract is unchanged: the 405 + `allowed` array and `/me/permissions` `apiOperations` still serialize derived + verbs (`export`, `search`, …), and `EffectiveObjectPermissionSchema.apiOperations` + now validates against `ApiOperationSchema`. `EffectiveApiMethods.explicitLegacy` + is removed (nothing is honored verbatim anymore); `API_METHOD_ORDER` remains as + a deprecated alias of `API_OPERATION_ORDER`. + + **Fail-closed tightening (#3545):** a PRESENT but non-array `apiMethods` (only + producible by a raw/out-of-band metadata write) now resolves to `deny-all` + instead of unrestricted — a policy that exists but cannot be read fails CLOSED. + + **Published JSON Schema diverges deliberately:** `data/ApiMethod.json` is the + strict six-value enum (a `z.preprocess` is not representable in JSON Schema), + so external JSON-Schema validators reject legacy values that the zod parse + would strip-and-warn. Treat the JSON Schema as the authored contract; the zod + tolerance exists for stored metadata. + + **objectql:** the P1 "explicit wins" transition is reclaimed — + `warnDeprecatedExplicitApiMethods` is replaced by `warnStrippedLegacyApiMethods` + (a permanent per-object diagnostic for schemas that reach the registry without + passing through Zod; the parse-time strip warning carries no object name). + + **platform-objects:** whitelist audit — `sys_business_unit`, + `sys_business_unit_member` (P1's explicit `import`/`export` reclaimed) and + `sys_user_preference` dropped their `apiMethods` entirely (each named all six + primitives = default-open). Read-only and deny-all whitelists are unchanged; + the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside + `apiEnabled: false`. + +- bc17d39: fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624) + + better-auth 1.7.0-rc.1 added fields to three models that the platform objects + never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped + field keeps its camelCase name, the adapter emitted columns no table had: + + | model | field | column now provisioned | + | :----------- | :---------------------------------------- | :---------------------------------------------------------- | + | `team` | `memberCount` | `sys_team.member_count` | + | `teamMember` | `membershipKey` | `sys_team_member.membership_key` | + | `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` | + + The team pair broke org creation outright. The organization plugin's team + sub-feature is on by default, so `POST /api/v1/auth/organization/create` + auto-creates a default team — and that insert died with `table sys_team has no +column named memberCount` _after_ the organization row had already committed. + Callers got an HTTP 500 on top of a half-created org: a real org row with no + default team behind it. Every multi-org deployment's create-org flow hit this. + + The two-factor pair broke the 2FA lockout path the same way: better-auth + guard-increments `failedVerificationCount` on each wrong code and stamps + `lockedUntil` past the threshold, so a wrong code 500'd instead of being + counted. All four columns are better-auth's own state — provisioned, readable, + and never written from the ObjectStack side. + + Existing environments pick the columns up through the driver's additive schema + sync; no data migration is needed. `member_count` backfills to 0 and + better-auth's own `syncTeamMemberCount` reconciles it on the next membership + change, and `membership_key` stays null on pre-upgrade rows, which better-auth + tolerates by falling back to the `(team_id, user_id)` pair. + + A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every + column the installed better-auth version can write exists on the platform + object backing it, across the auth manager's whole model surface. The ADR-0092 + D7 guard only ever caught _collisions_ between our extension fields and + better-auth's, so a bump that adds a brand-new field passed the build and failed + at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId` + regression. The next one fails the build instead. + +- 524151c: fix(i18n): clear the accumulated drift in the generated translation bundles + + The committed bundles had fallen behind the spec on three independent axes. + `os i18n extract` (merge mode — every existing translation is preserved) + reconciles all of them: + + **Keys the spec no longer has**, still carrying translations in + `*.metadata-forms.generated.ts`. All three were removed deliberately and are + now _rejected_ by the schema, so their entries were dead weight: + + - `capabilities.trash` / `capabilities.mru` — `enable.trash`/`enable.mru` + retired in the 16.x line (#2377), with tombstone guidance in + `UNKNOWN_KEY_GUIDANCE`. + - agent `visibility` — removed 2026-07 (#1901). + + **Keys the spec gained** but the bundles never learned: the + `summaryOperations.*` sub-fields (`object` / `function` / `field` / + `relationshipField` / `filter`), and `sys_invitation.business_unit_id` / + `positions` from the ADR-0105 D8 placement work. + + **Objects stuck on empty strings.** `sys_migration`'s labels and help text were + committed as `""` in the ja-JP and es-ES bundles, which renders as _blank_ in + those locales rather than falling back to anything readable. They now carry the + schema text like every other untranslated key. + + No API or schema change — this only affects what the UI displays. + +- d1cabaa: fix(i18n): translate the SSO / SCIM / user-position / import-job admin objects + + Four live, UI-facing system objects were registered but never added to their + package's i18n extract config, so non-English admins saw raw English `label` + metadata: + + - `sys_sso_provider`, `sys_scim_provider` (platform-objects) — identity-provider + admin grids plus the register / verify-domain actions. + - `sys_user_position` (plugin-security) — delegated position assignment + (`userActions` create/edit/delete); its sibling `sys_user_permission_set` was + already translated, so this closes an inconsistency. + - `sys_import_job` (platform-objects) — import history / progress, alongside the + already-translated `sys_job` / `sys_job_run`. + + Adds each object to its package's `scripts/i18n-extract.config.ts` and supplies + real zh-CN / ja-JP / es-ES translations across all four locale bundles, and + extends the bundle-ownership guards' `OWNED_OBJECTS` to cover them. The + orphan-only guards from #3502 could not catch this "owned-and-live-but-never- + extracted" gap. + +- 85e1e4e: feat(rest): `treatAsHistorical` import option — skip the state machine for historical-data migration (#3479) + + Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced + the FSM entry point on every INSERT, so importing established historical facts — + a batch of already-`closed` tickets, `closed_won` deals, `completed` projects — + was rejected row-by-row with `invalid_initial_state`, blocking the core + data-migration path. Unlike the seed case it was visible (per-row errors), but it + still functionally blocked a legitimate use. + + - **spec**: `ExecutionContext.skipStateMachine` — a general, server-set flag (the + seed-specific `seedReplay`'s sibling) that skips the `state_machine` rule for a + write; `ImportRequestSchema.treatAsHistorical` (default `false`) — the user-facing + import option. + - **objectql**: the engine now skips the state machine for `seedReplay` OR + `skipStateMachine` (one helper), covering both seed replay and historical import. + - **rest**: the import runner sets `skipStateMachine` on the write context iff the + request opts into `treatAsHistorical`; default off, so a normal import still walks + the FSM (the strict behavior is the default). Import **undo** now also carries + `skipStateMachine`, since restoring a prior snapshot re-writes an earlier state + that need not be a legal transition from where the row is now. + - **platform-objects**: `sys_import_job.treat_as_historical` audit column (additive). + + Scope is identical to the seed exemption: ONLY the `state_machine` rule is skipped; + field shape, `format`, `cross_field`, `script` all still run. The objectui import + wizard checkbox is a separate follow-up. + +- 4921a95: fix(i18n): platform-objects' 231 untranslated strings were 1 — close the real gap and stop the phantom (#3762) + + Closes the rest of #3762. The remaining item was recorded as "platform-objects + is 77 strings short per locale, in `apps.*` / `dashboards.*`, and its + `--objects-only` extract cannot scaffold them — needs an emit decision (drop + `--objects-only`, or a companion `.apps.generated.ts`) before any translating." + + Measured, the premise did not hold. Of the 77 declared keys per locale, **76 + were already translated** in the hand-authored `.ts` files and had been + for months. Exactly one was genuinely missing — + `apps.studio.navigation.nav_app_builder.label`, absent in all four locales + including `en`. The 231 was a measurement artifact: this config declares + SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its + `translations` merge baseline listed only the two GENERATED subtrees + (`objects`, `metadataForms`), so coverage counted every hand-authored + app/dashboard key as untranslated. + + **Neither proposed emit is right, and the second would have caused damage.** + The Setup app is a shell of empty group anchors; its ~25 menu entries are + contributed at runtime by `SETUP_NAV_CONTRIBUTIONS` and by capability plugins + (ADR-0029 D7). A bundle generated from a static walk of `SETUP_APP` is + therefore structurally incomplete, and regenerating over the hand-authored + files would have **deleted 40 live nav translations per locale**. Dropping + `--objects-only` fails differently: `kind: 'full'` folds all 803 metadata-form + keys into `.objects.generated.ts` and renames the export the baseline + imports. + + The split is correct as it stands and is now written down: `objects` / + `metadataForms` are generated and gated by the bundle-drift check; `apps` / + `dashboards` / `pages` are hand-authored and gated by the coverage ratchet. + What was wrong was only that the baseline omitted the hand-authored half. + + - Extract config's `translations` now carries the per-locale assemblers, with + `objects`/`metadataForms` still pinned to the committed generated files. + Safe for the emit — `--objects-only` writes `data.objects` alone, so nothing + added here can reach a generated bundle, and `check:i18n` stays in sync + across all nine packages. + - `nav_app_builder` translated in all four locales, wording taken from the + repo's own precedent for "builder" (`构建器` / `ビルダー` / `generador`). + - `nav_workflows` removed from all four: its menu entry is gone from + `STUDIO_APP` and nothing contributes to that app, so the translation was + dead. + - Coverage ratchet baselined 231 → **0**, making platform-objects the ninth + package where the ratchet is a strict gate — verified to go red on a single + removed translation. + - A local, CLI-independent parity test walks the statically declared Studio and + Account navigation plus the dashboard's widgets and asserts a translation in + every locale — and the reverse, that no translation survives its nav item. + Both directions verified to fail before passing. + + An untranslated nav id is invisible in the UI — it falls back to the app's + English label, so a Chinese Studio menu just shows one English entry among + thirty. That is why this needed a gate rather than a one-time sweep. + + Still out of scope: the ~25 Setup entries contributed at runtime. Bringing them + under a static gate needs either an objectql dependency in this package (it + depends only on spec and metadata-core) or extractor support for + `navigationContributions` — a real follow-up, not something to half-do here. + +- 5487c20: fix(auth): provision `sys_scim_provider.provider_key` — SCIM provider creation failed the moment SCIM was switched on (#3653) + + `@better-auth/scim` declares `providerKey` as `required: true, unique: true` + and writes it on every provider insert — a derived `:` + uniqueness key it owns end to end. `sys_scim_provider` never provisioned the + column, so the adapter emitted a `provider_key` no table had: the same failure + shape as #3624, waiting behind the `OS_SCIM_ENABLED` flag. + + Found by extending the better-auth parity gate to `@better-auth/sso` and + `@better-auth/scim`. Neither accepts a `schema` option, so `getAuthTables()` is + blind to them and they were excluded when that gate shipped; the gate now reads + each plugin's own declared schema and resolves columns the way the adapter + actually does for a bridged model. `@better-auth/sso` came back fully covered. + + Existing environments pick the column up through the driver's additive schema + sync; it stays null on pre-upgrade rows, which the nullable UNIQUE index admits. + +- 4d00b13: feat(spec)!: remove `tool.requiresConfirmation` — a safety flag nothing enforced (#3715, ADR-0033 §2) + + `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read + it. Not the LLM tool set (a tool reaches the model as name/description/parameters + only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the + MCP bridge — which derives `destructiveHint` from a hardcoded name list. Setting + it on a destructive tool produced **no pause**. + + For an ordinary dead property that is untidy. For a **safety** property it is + false compliance, which is the case ADR-0049 exists for: an author gates a + destructive tool, sees the flag accepted, and ships believing a human is in the + loop. It is made worse by the near-miss — `action.ai.requiresConfirmation` has + the same name and **does** work, so the mistake reads as correct in review. + ADR-0033 §2 already resolved to delete this one. + + ## Migration + + - **FROM:** `requiresConfirmation: true` on a tool definition + - **TO:** put the operation behind an action and set `ai.requiresConfirmation: +true` there — that is the flag the HITL approval queue reads + (`packages/runtime/src/action-execution.ts`) and the only path that actually + stops execution. + - For AI _metadata_ mutations there is nothing to migrate: the ADR-0033 + draft/publish workspace is the gate — nothing is live until a human publishes. + + **`ToolSchema` is now `.strict()`.** This is load-bearing, not tidying. Removing a + key from a non-strict schema swaps one silent no-op for another: zod strips the + key wordlessly, the author keeps writing it, and the safety flag goes on meaning + nothing — the "silent strip" ADR-0032 / #1535 closed for objects. The retired key + now **rejects**, and the error carries the FROM → TO above, because a parse error + is the one channel every consumer bumping `@objectstack/spec` is guaranteed to + hit. + + Strictness applies to _all_ unknown keys on a tool definition, so a typo + (`buildIn`, `catagory`) is now a located parse error instead of a silently + dropped field. + + Also removed: the Studio form row, its four generated locale bundles (the + `en`/`zh-CN`/`ja-JP`/`es-ES` strings still promised _"Ask user to approve before + executing (for destructive actions)"_ — a translated false promise), the + liveness-ledger entry, and the generated reference-doc row. + + objectui's `ToolPreview.tsx` reads the field via `!!d.requiresConfirmation`, so it + degrades to "not shown" with no error; removing that badge is a follow-up in that + repo. + +- 9aa5510: fix(i18n): ship the missing object-translation keys for the better-auth 1.7 and ADR-0105 D6 fields (#3624 follow-up) + + The generated object-translation bundles predate two rounds of field additions, + so six fields had no entry in **any** locale and fell back to their raw schema + labels in every UI surface that reads the bundle: + + - `sys_team.member_count`, `sys_team_member.membership_key`, + `sys_two_factor.failed_verification_count` / `locked_until` — the better-auth + 1.7 columns provisioned in #3647. + - `sys_organization.parent_organization_id` / `sort_order` — the same gap left + by the earlier ADR-0105 D6 group-structure work. + + Regenerated with `os i18n extract` (merge mode, so every existing translation is + preserved — the diff is purely additive). No API or schema change; the fields + themselves already shipped. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 2672bdedd9..603248a27c 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "16.1.0", + "version": "17.0.0-rc.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/driver-memory/CHANGELOG.md b/packages/plugins/driver-memory/CHANGELOG.md index 7f220fc31f..e918e508a6 100644 --- a/packages/plugins/driver-memory/CHANGELOG.md +++ b/packages/plugins/driver-memory/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/driver-memory +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/driver-memory/package.json b/packages/plugins/driver-memory/package.json index f01d51e085..1786ea7ff0 100644 --- a/packages/plugins/driver-memory/package.json +++ b/packages/plugins/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "16.1.0", + "version": "17.0.0-rc.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/plugins/driver-mongodb/CHANGELOG.md b/packages/plugins/driver-mongodb/CHANGELOG.md index f54b574833..9168221f94 100644 --- a/packages/plugins/driver-mongodb/CHANGELOG.md +++ b/packages/plugins/driver-mongodb/CHANGELOG.md @@ -1,5 +1,293 @@ # @objectstack/driver-mongodb +## 17.0.0-rc.0 + +### Minor Changes + +- d1557d9: feat(driver-mongodb)!: declare the driver single-tenant and refuse to boot multi-tenant (#3724) + + `MongoDBDriver` implements **no row-level tenant isolation** — it never reads + `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are not + stamped with a tenant column. The layer the SQL driver has (`resolveTenantField` + + - `applyTenantScope`) simply does not exist here, while everything above the + driver — object metadata's `tenancy` block, `applySystemFields` injecting + `organization_id`, the engine threading `tenantId` into every driver call — + operates on the assumption that tenant isolation is a platform guarantee. Point + a multi-tenant deployment's datasource at Mongo and every query read, updated + and deleted other tenants' documents, silently. + + Rather than serve unisolated, the driver now fails fast at startup: + + - The **constructor** and `connect()` call `assertSingleTenantPosture()`, which + refuses any tenancy posture other than `single` (`OS_TENANCY_POSTURE=group` / + `isolated`, including the posture derived from `OS_MULTI_ORG_ENABLED=true`), + resolved through the shared `resolveTenancyPosture()` so the driver can never + disagree with auth / the registry / the CLI about the mode. The check sits in + the constructor because that is the earliest seam — it fails before a host can + hand the driver anywhere — and `connect()` re-checks in case a host flips the + posture in between. (It originally had to live in the constructor because + `ObjectQLEngine.init()` _caught_ a driver's connect rejection and booted + anyway; that is fixed in the same release, #3741, so both seams abort boot.) + - `syncSchema()` / `syncSchemasBatch()` call `assertObjectsNotTenantScoped()` and + refuse objects declaring `tenancy.enabled: true`, naming every offender in one + message. + - `objectstack serve` / `dev` (CLI) now re-throw this error out of the + auto-driver-registration block instead of swallowing it, so boot exits 1 with + the actionable message — the same treatment `UnsupportedDriverError` already + gets. Matched duck-typed by `code`, so the CLI takes no dependency on the + driver package. + + Both throw `MongoDBMultiTenantUnsupportedError` with + `code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'`, a message that names the detected + signal, the remedy, and `@objectstack/driver-sql` as the multi-tenant option. + + There is deliberately **no override env var**: an escape hatch would restore + exactly the silent non-isolation this guard removes. Single-tenant deployments — + every currently-working Mongo deployment — are unaffected. + + This is option B of #3724. Implementing real row-level isolation (option A) + remains open; the `unique` index shape stays single-field until then, which is + now correct by construction rather than by omission. + +- b90086a: fix(driver-sql)!: `unique` materializes per tenant, ending its contradiction with the per-tenant autonumber sequence (#3696) + + `unique: true` became a **single-column global index that ignored `tenancy` + entirely**, while the autonumber sequence table is keyed by + `(object, tenant_id, field, scope)` and hands every tenant its own counter + starting at 1. Two subsystems of the same platform contradicted each other: + tenant B's `PROD-00001` was rejected by an index it could not see — **no user + did anything wrong**, the platform's left hand refused what its right hand + issued. + + The rejection also doubled as a **cross-tenant existence oracle**: a UNIQUE + violation told tenant B that some _other_ tenant held the value, enumerable by + probing emails / codes / names. + + **The contract now:** + + | Declaration | Materializes as | + | -------------------------------- | --------------------------------------------------------------- | + | `unique: true` + tenant column | composite `(tenantField, field)` — unique **within** the tenant | + | `unique: true`, no tenant column | single-column — single-tenant DDL is byte-identical to before | + | `unique: 'global'` | single-column, always platform-wide | + + The tenant column comes first in the composite, so the index also serves the + `WHERE tenant = ?` prefix scans every tenant-scoped read issues. + + **Declared `indexes[]` are deliberately unchanged.** They are materialized over + exactly the columns listed — no tenant column is injected. The author already + spells them out, per-tenant ones have always been written explicitly + (`fields: ['organization_id', 'code']`), and many are legitimately platform-wide + (a DNS hostname, a reserved slug, an external provider id). `'global'` is + accepted there as a synonym of `true` so one vocabulary covers both spellings. + + **Migration is automatic and cannot fail.** Legacy indexes + (`
__unique` from knex, `uniq_
_` from the drift-rebuild + path) are retired inline at schema-sync time. The old global constraint is + strictly stronger than the new per-tenant one, so existing rows satisfy the + replacement by construction — no dedup, no cleanup, no data touched. It + converges at sync rather than waiting for a deliberate `os migrate` run because + a deployment that never ran migrate would otherwise stay broken. + + **Upgrading — audit your `unique: true` fields.** On a tenant-scoped object the + constraint is now per tenant. Anything that must stay platform-wide has to say + so: + + ```ts + hostname: Field.text({ unique: "global" }); // no two tenants may claim it + ``` + + Note the reach: `applySystemFields` injects `organization_id` into every + registered object unless it opts out, and the driver falls back to that column + when no `tenancy.tenantField` is declared — so most objects are tenant-scoped. + Typical candidates for `'global'`: DNS hostnames, reserved slugs, external + provider ids (Stripe customer/subscription), device identities. + + Postgres materializes `col.unique()` as a table CONSTRAINT rather than a bare + index, so the retirement tries `DROP CONSTRAINT` before `DROP INDEX` — + `DROP INDEX` alone would have made the migration a no-op on exactly the + deployments that matter most. + + `@objectstack/driver-mongodb` accepts the new declaration but keeps single-field + indexes: it implements no row-level tenancy at all (no tenant predicate on read, + no tenant stamp on write), so a `(tenant, field)` index would advertise an + isolation it does not deliver. Tracked separately. + +### Patch Changes + +- 030125b: feat(objectql)!: `init()` refuses to boot when a data driver fails to connect (#3741) + + `ObjectQLEngine.init()` wrapped every driver's `connect()` in a try/catch, logged + one error line, and carried on. A server whose database was unreachable therefore + "started successfully" — health endpoints could even stay green — and then failed + every request with an error that reads nothing like _the database is down_. The + warning it printed (`Operations may recover via lazy reconnection or fail at query +time`) was half fiction: grep the repo and no reconnection exists in `driver-sql` + or `driver-mongodb`, so only the "fail at query time" half was ever real. The + caller made it worse — `ObjectQLPlugin.start()` runs `syncRegisteredSchemas()` + immediately after `init()`, issuing DDL against a driver that isn't there. + + The structural half of the bug was worse than the operational one: the catch + removed a driver's ability to **refuse startup at all**. Any fatal startup check — + licence, server version, incompatible configuration, missing capability, not just + an unreachable socket — is expressed by throwing from `connect()`, and every one + of them was silently downgraded to a runtime error. That is why driver-mongodb's + multi-tenancy guard (#3724 / #3734) had to be hoisted into its constructor. + + - `init()` now **throws** `DriverConnectError` (`code: 'ERR_DRIVER_CONNECT'`) + when any boot-registered driver's `connect()` rejects, aborting kernel + bootstrap. It still attempts every driver first, so one failed boot names all + of them. The message is self-contained — each failed driver and its cause — + because the CLI prints `error.message` alone; the first cause is also attached + as `error.cause`. Exported from both `@objectstack/objectql` and + `@objectstack/objectql/core`. + - `connect()` is now a supported place for a driver to veto boot. Startup + validation that needs a live connection (server version, capability probes) + no longer has to be forced into a constructor. + - The misleading "lazy reconnection" warning is gone. + - New escape hatch `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` + (`resolveAllowDriverConnectFailure()` in `@objectstack/types`) restores the old + lenient boot, but loudly: a `DEGRADED BOOT` banner names the failed drivers and + states that they are never retried or reconnected and that every query and + schema sync routed to them will fail for the process lifetime. The banner goes + to stderr as well as the logger, because `os serve` swallows all of stdout + during boot and `Logger` routes `warn` there — logger-only, the one message + that matters would be invisible in exactly the deployment the flag is for. + Defaults off. + + **Migration.** No code or config change is needed for a correctly configured + deployment — a driver that connected before still connects. A deployment that was + _silently_ booting without its database now fails the boot instead, with the + driver name and cause in the error; fix the datasource configuration (typically + `OS_DATABASE_URL`, credentials, or network reachability). To keep booting without + it — deliberately, and knowing every request that touches it will fail — set + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json index 6125ceda93..e60354acde 100644 --- a/packages/plugins/driver-mongodb/package.json +++ b/packages/plugins/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "16.1.0", + "version": "17.0.0-rc.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/plugins/driver-sql/CHANGELOG.md b/packages/plugins/driver-sql/CHANGELOG.md index 2c28d87cbf..0986581b75 100644 --- a/packages/plugins/driver-sql/CHANGELOG.md +++ b/packages/plugins/driver-sql/CHANGELOG.md @@ -1,5 +1,725 @@ # @objectstack/driver-sql +## 17.0.0-rc.0 + +### Minor Changes + +- 32d3800: fix(driver-sql): bound a connection attempt at 10s, and correct the "no reconnection" claim (#3769, #3759) + + Two related corrections, both from measuring what #3741/#3751/#3765 had only asserted. + + **The claim was wrong.** #3751 and #3765 shipped several statements that drivers + never reconnect — "there is no lazy reconnection", "NOT retried and NOT + reconnected", "stays disconnected for the process lifetime". Measured, both + drivers recover on their own: + + - driver-mongodb: killing a real `mongod` and restarting it on the same port, + the _same_ driver instance served the next write successfully (13ms), with no + reconnect call from us — the official driver's topology monitor handles it. + - driver-sql: a knex/pg pool is not poisoned by an outage. Its error tracks live + server state (`ECONNREFUSED` while down → a handshake error once a listener is + back → `ECONNREFUSED` again), i.e. every acquire opens a fresh connection. + `storage-driver.ts` also configures `pool.min: 0`, so no stale idle + connections are held. + + The original reasoning grepped this repo for `reconnect`, found nothing, and + concluded recovery does not happen — but the recovery lives in the client + libraries, not in our code. The claims are now corrected in `DriverConnectError`, + the `DEGRADED BOOT` banner, `resolveAllowDriverConnectFailure`'s docs, and the + drivers / self-hosting pages. + + **Fail-fast at boot is unchanged and still correct** — the reason is just + different. It is not that the connection can never return; it is that the _boot + sequence_ never re-runs. A driver that missed `init()` also missed + `syncRegisteredSchemas()`, so its tables can simply not exist even after the + database comes back. The banner now says that. + + **The real defect underneath.** `SqlDriver` passed its config to knex untouched, + so a database endpoint that accepts TCP but never completes the handshake — an + overloaded instance, a half-open firewall, a load balancer mid-failover — made + every query wait out tarn's 30s default, then fail with `Timeout acquiring a +connection. The pool is probably full`, pointing an operator at pool sizing + instead of the network. With a small `pool.max` a few such queries saturate the + pool and everything else queues. + + `SqlDriver` now defaults `pool.createTimeoutMillis` to **10s**, matching + driver-mongodb's existing `connectTimeoutMS ?? 10_000` so both drivers give up on + an unreachable server at the same point. A host that sets its own + `createTimeoutMillis` is left alone. + + **Migration.** None for a healthy datasource. A deployment that deliberately + relies on connection establishment taking longer than 10s (a slow cross-region + replica) should set `pool.createTimeoutMillis` explicitly on its `SqlDriver` + config. + + Not fixed here, tracked in #3769: knex still reports the bounded wait as "the + pool is probably full". An accurate message needs a dialect-specific connect + timeout (pg's `connectionTimeoutMillis`), which changes the shape of `connection` + and would regress the startup banner's URL display. + +- 5d4de37: fix(objectql,driver-sql)!: a group key is the column's value, in the shape `find()` presents it (#3849) + + `groupBy: ['qty']` now returns `3`, not `'3'`. `groupBy: ['won']` returns `true` / + `false`, not `'true'` / `'false'` on one path and `1` / `0` on the other. A bucket + key is a column value, so there is one right answer for what it looks like — + whatever that column looks like on a `find()` row — and all three paths that + produce one now give it. + + ### What was wrong + + Three code paths produce a group key, and no two of them agreed: + + | | `qty` (number) | `won` (boolean) | + | ------------------------- | ---------------- | ------------------------------- | + | `find()` | `3` number | `true` boolean | + | `aggregate()` pushed down | `3` number | `0` / `1` **number** | + | in-memory fallback | `'3'` **string** | `'false'` / `'true'` **string** | + + Two independent causes: + + - `applyInMemoryAggregation` ran every key through `String()`. The pushed-down + path never did. + - The pushed-down path returns raw builder output. #3797 taught it to present + temporal columns the way `formatOutput` does on a `find()` row, but not the + boolean and numeric repairs — so a SQLite boolean, which has no native type and + is stored as `0`/`1`, surfaced as an integer from `aggregate()` and as a real + boolean from `find()`. + + `engine.aggregate` chooses between the two aggregate paths per query — by whether + the driver aggregates natively, whether it advertises the requested granularity, + and whether the reference timezone is UTC — so the same column changed shape with + no change to the data or the query. + + ### Why it mattered + + The measures were always right, which is why this went unnoticed. What broke was + downstream code that probes a raw `Map` keyed by the value's own type. `Map` + lookup is SameValueZero, so `'1'` never finds `1`: + + - **Select-option labels** (`dimension-labels.ts`) — the label table is keyed by + the option's own `value`. A numeric option value never matched a stringified + key, so the chart rendered the raw stored value instead of its label. + - **Lookup / master-detail labels** — the id → record-name table is built by an + inner query that always pushes down (raw ids), then probed with the outer + query's keys, which may be in-memory (stringified). With a numeric primary key + — routine for external/federated objects — every label missed. + - **Cross-object rebucketing** (`cross-object-rebucket.ts`) — the FK → attribute + map is built and probed the same way, and a miss is not a fallback but + `RESTRICTED_BUCKET`. A numeric FK filed **every row** under `'(restricted)'`: + one bar, correct grand total, no error. + - **Drill-through** — the raw dimension value goes into the drill filter + verbatim, so a boolean dimension drilled from the in-memory path sent + `{ won: 'true' }` to SQLite, whose INTEGER column cannot equal the text + `'true'`. Zero rows. + + ### What changed + + - `applyInMemoryAggregation` (`@objectstack/objectql`) emits the value verbatim. + Its rows come straight from `driver.find()`, so passing the value through is + what makes the key equal the column's own read shape. + - The internal composite bucket id is now type-preserving, so `1` and `'1'`, + `true` and `'true'` stay distinct groups rather than merging on the way in. + BigInt is encoded explicitly — `JSON.stringify` throws on it, and a value that + used to bucket under `String()` must not start crashing the aggregate. + - `SqlDriver.aggregate` / `.distinct` (`@objectstack/driver-sql`) present group + keys and `min`/`max` results with the same rules `formatOutput` applies on a + `find()` row, generalizing the #3797 temporal fix to boolean and numeric + columns. The `protected` helpers behind it are renamed accordingly + (`temporalFieldKind` → `readPresentationKind`, `presentTemporalValue` → + `presentReadValue`, `presentTemporalColumns` → `presentReadColumns`) and the + kind union is exported as `ReadPresentationKind`. + + Date-bucketed `groupBy` items are unaffected: `bucketDateValue` and the dialect + bucket expressions both produce canonical string labels, and #3839 already pinned + their empty bucket. + + ### Gate + + `packages/qa/dogfood/test/group-key-read-shape-parity.test.ts` measures both + aggregate paths against `find()` for a number, boolean and text column, on + `driver-sql` and `driver-sqlite-wasm`. It asserts the runtime TYPE, not just the + value — folding both sides through `String()` is the reflex that hid this in the + first place and would make the check pass against the bug it exists to catch. + + Each half was confirmed to fail the gate on its own: reverting only the + in-memory change reddens the number and boolean cases, reverting only the driver + change reddens the boolean cases with `0` against `false`. + +- dac6a08: feat(driver-sql)!: make index drift visible to `os migrate plan` — no more silent DDL at boot (#3728) + + The #3696 unique-scope migration converged **in place**: `syncTableIndexes` ran a + `DROP` + `CREATE UNIQUE INDEX` during `initObjects`, in every environment, + leaving one log line behind. `os migrate plan` showed nothing, because + `detectManagedDrift` was column-only — `ManagedDriftOp` had no index dimension at + all. An operator who wanted to review the DDL before it reached their database + had no way to, and a managed schema was being auto-altered in production, which + the #2186 contract explicitly forbids. + + Index drift is now a first-class dimension, reconciled through the same path as + column drift: + + - **`syncTableIndexes` is additive only.** It creates indexes; it never drops or + rewrites one. `dropLegacyGlobalUniques` is gone. + - **New `DriftOp` variants** — `replace_unique_index` (safe: retire the legacy + platform-wide unique in favour of the tenant composite), `create_index` (safe), + `recreate_index` (needs-confirm; destructive when it tightens to `UNIQUE`), and + `drop_index` (destructive). + - **`detectManagedDrift` reports them**, `os migrate plan` renders them (index + ops display as `table [index_name]`), and `os migrate apply` executes them. + Index DDL is portable, so it applies directly on every dialect — no SQLite + table rebuild. + - **`replace_unique_index` creates before it drops**, so uniqueness is never + unenforced mid-migration and a failed create leaves the schema untouched. + - **Declared `indexes[]` drift is covered too**: an index metadata declares but + the database lacks, and one whose definition no longer matches the declaration + (the additive sync skips those by name, so they could never self-heal). + - **Orphan detection is limited to ObjectStack's own generated naming** + (`uniq_…` / `idx_…`, plus the pre-#3696 `
__unique` knex + spelling). A hand-rolled operational index is never reported as drift and + `--allow-destructive` will not delete it. + + **Behaviour change.** Boot no longer rewrites the index unconditionally. Dev + (`autoMigrate: 'safe'`, what `os dev` / `os serve` use) still self-heals on + restart, so local workflows are unchanged. Production now **warns** with an + actionable `os migrate` hint and leaves the schema alone — the deployment stays + on the legacy global unique (multi-tenant inserts still collide) until someone + runs `os migrate apply`. That is the deliberate trade: a visible, pre-inspectable + migration instead of an invisible one. + + Also fixed: `managedObjectIndexes` was never cleared when an object dropped its + `indexes[]`, so drift detection kept expecting an index nobody declared. + + `SchemaDiffEntryKind` gains `index_mismatch` and `unmapped_index`. + +- 7457a09: fix(driver-sql): give the bounded connection attempt an accurate error message (#3769) + + #3781 bounded a connection attempt at 10s via `pool.createTimeoutMillis`, which + stopped the 30s hang but kept knex's own wording: `Timeout acquiring a +connection. The pool is probably full`. The pool is not full — the server never + completed the handshake — so that message sends an operator to tune `pool.max` + while the network is what is broken. This is the same defect class the boot + guard in #3741 was about: an error that reads nothing like its cause. + + `SqlDriver` now also sets the **dialect's own** connect timeout, which fails with + a message that names what happened: + + | client | key | message | + | ------------------------------------------------ | ------------------------- | ------------------- | + | `pg` / `postgres` / `postgresql` / `cockroachdb` | `connectionTimeoutMillis` | `timeout expired` | + | `mysql` / `mysql2` | `connectTimeout` | `connect ETIMEDOUT` | + + Carrying the timeout requires `connection` to be an object, so a URL string is + moved into the dialect's URL slot (`connectionString` for pg, `uri` for mysql2). + Verified against a black-holing listener that both forms still reach the URL's + own host/port and still honour `?sslmode=require`. SQLite is untouched — opening + a file has no handshake to time out. + + **The two bounds are deliberately unequal.** They race and knex wins a tie, so + equal values would let the pool timeout fire first and the accurate message would + never be seen. The dialect timeout is the effective bound at **10s**; the pool + timeout is a strictly looser backstop, raised from 10s to **15s**, reached only + by a dialect with no connect-timeout knob or one that ignores the one we set. + + `driver.config` keeps the shape the author passed — the rewrite applies only to + what knex receives. Two existing readers depend on that: `serve.ts`'s startup + banner and `createDatabase()`, which parses the URL to swap in the maintenance + database. A test pins it. + + `createDatabase()`'s own admin connection now gets the same bound; it is opened + during boot against the very server we already suspect is unreachable, so it must + not be the one place that still waits 30s. + + **Migration.** None for a healthy datasource. A deployment that deliberately + needs longer than 10s to establish a connection (a slow cross-region replica) + sets `connection.connectionTimeoutMillis` (pg) or `connection.connectTimeout` + (mysql2) explicitly, and it is left alone. + +- b90086a: fix(driver-sql)!: `unique` materializes per tenant, ending its contradiction with the per-tenant autonumber sequence (#3696) + + `unique: true` became a **single-column global index that ignored `tenancy` + entirely**, while the autonumber sequence table is keyed by + `(object, tenant_id, field, scope)` and hands every tenant its own counter + starting at 1. Two subsystems of the same platform contradicted each other: + tenant B's `PROD-00001` was rejected by an index it could not see — **no user + did anything wrong**, the platform's left hand refused what its right hand + issued. + + The rejection also doubled as a **cross-tenant existence oracle**: a UNIQUE + violation told tenant B that some _other_ tenant held the value, enumerable by + probing emails / codes / names. + + **The contract now:** + + | Declaration | Materializes as | + | -------------------------------- | --------------------------------------------------------------- | + | `unique: true` + tenant column | composite `(tenantField, field)` — unique **within** the tenant | + | `unique: true`, no tenant column | single-column — single-tenant DDL is byte-identical to before | + | `unique: 'global'` | single-column, always platform-wide | + + The tenant column comes first in the composite, so the index also serves the + `WHERE tenant = ?` prefix scans every tenant-scoped read issues. + + **Declared `indexes[]` are deliberately unchanged.** They are materialized over + exactly the columns listed — no tenant column is injected. The author already + spells them out, per-tenant ones have always been written explicitly + (`fields: ['organization_id', 'code']`), and many are legitimately platform-wide + (a DNS hostname, a reserved slug, an external provider id). `'global'` is + accepted there as a synonym of `true` so one vocabulary covers both spellings. + + **Migration is automatic and cannot fail.** Legacy indexes + (`
__unique` from knex, `uniq_
_` from the drift-rebuild + path) are retired inline at schema-sync time. The old global constraint is + strictly stronger than the new per-tenant one, so existing rows satisfy the + replacement by construction — no dedup, no cleanup, no data touched. It + converges at sync rather than waiting for a deliberate `os migrate` run because + a deployment that never ran migrate would otherwise stay broken. + + **Upgrading — audit your `unique: true` fields.** On a tenant-scoped object the + constraint is now per tenant. Anything that must stay platform-wide has to say + so: + + ```ts + hostname: Field.text({ unique: "global" }); // no two tenants may claim it + ``` + + Note the reach: `applySystemFields` injects `organization_id` into every + registered object unless it opts out, and the driver falls back to that column + when no `tenancy.tenantField` is declared — so most objects are tenant-scoped. + Typical candidates for `'global'`: DNS hostnames, reserved slugs, external + provider ids (Stripe customer/subscription), device identities. + + Postgres materializes `col.unique()` as a table CONSTRAINT rather than a bare + index, so the retirement tries `DROP CONSTRAINT` before `DROP INDEX` — + `DROP INDEX` alone would have made the migration a no-op on exactly the + deployments that matter most. + + `@objectstack/driver-mongodb` accepts the new declaration but keeps single-field + indexes: it implements no row-level tenancy at all (no tenant predicate on read, + no tenant stamp on write), so a `(tenant, field)` index would advertise an + isolation it does not deliver. Tracked separately. + +### Patch Changes + +- fa3d0cf: feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) + + `@objectstack/spec/data` now owns the runtime VALUE shape of every field type + (`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`, + `NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`, + `STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared + `isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The + four consumers that each hand-copied this knowledge (objectql record-validator, + rest import-coerce, driver-sql column classification, qa conformance) now + derive from the spec, and the field-zoo round-trip MATRIX is asserted against + the contract so the two cannot drift. + + **Write-path change (objectql, warn-first):** previously-unvalidated types — + single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/ + `video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`, + `vector` — are now checked against the contract. A violation **logs a warning + and passes** in this release (legacy rows must not strand their records); + set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a + `400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor + (ADR-0104 R1/R2). + + **Deprecations (removal rides the next spec major), FROM → TO:** + + - `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's + value is a **bare number** everywhere in the runtime (validator, SQL `float` + column, import coercion, field-zoo oracle); the currency code lives in field + config. Use `valueSchemaFor({type: 'currency'})`. + - `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema` + (`{lat, lng}`) — the shape the platform actually stores. + - `AddressSchema` is **adopted** (unchanged) as the enforced `address` value + contract via `AddressValueSchema`. + + No stored data changes shape; the contract codifies deployed reality + ("reality wins", ADR-0104 D1). + +- c7f4417: fix(driver-sql,analytics): stop `aggregate()` / `distinct()` leaking SQLite's raw epoch storage (#3797) + + Both returned `await builder` directly, without the `formatOutput` pass every + `find()` row gets. On SQLite — the one dialect where a `Field.datetime` is + stored as INTEGER epoch milliseconds rather than a native timestamp — that raw + storage form went straight to the caller: + + | call | before | after | + | -------------------------------------- | ---------------------------- | -------------------------------- | + | `find()` | `"2026-01-10T09:00:00.000Z"` | unchanged | + | `distinct('closed_at')` | `[1768035600000]` | `["2026-01-10T09:00:00.000Z"]` | + | `aggregate()` `max(closed_at)` | `1768035600000` | `"2026-01-10T09:00:00.000Z"` | + | `aggregate()` `groupBy: ['closed_at']` | key `1768035600000` | key `"2026-01-10T09:00:00.000Z"` | + + Same root cause as #3773, different exit. `Field.date` was never affected — it + is ISO TEXT on every dialect, so its storage form already equals its + presentation. + + The visible surfaces were a `_max`/`_min` measure over a datetime (a "last + closed" KPI tile rendered `1768035600000`) and a `groupBy` on a raw datetime + dimension, which also disagreed with the in-memory `applyInMemoryAggregation` + fallback — that one consumes already-formatted `find()` rows, so the same + dataset changed key type depending on which path served it. + + Which columns hold an instant is now recorded while the statement is built, + because that is the only point where a column name and its meaning are both + known: a `min()` lands under its alias and never under the field name, while a + date-BUCKETED column lands under the field name but holds a label (`'2026-01'`) + rather than an instant. Matching on names afterwards gets both backwards. + + `distinct()` additionally re-deduplicates after presenting: SQL `DISTINCT` + compares STORED values, and one SQLite datetime column holds both INTEGER and + TEXT forms, so two rows recording the same instant survived as two and then + presented identically. It has no in-repo callers today; this keeps it honest + rather than leaving a second convention in the driver. + + **`cross-object-rebucket` was fixed alongside it, because presenting min/max + correctly is what exposed it.** `recombine()` coerced every operand with + `Number()`, which silently depended on receiving an epoch: handed the ISO string + the driver now returns it produced `NaN`, and on Postgres/MySQL (where knex + returns a `Date`) it had always flattened the value back to an epoch integer one + layer above the driver. `min`/`max` now order by the instant and return the + winning value in the shape it arrived in; `sum`/`count` stay numeric. + +- cf5e033: fix(driver-sql): `$or` branches AND their own contents again — every `$or` filter was widened + + `applyFilterCondition` passed `logicalOp='or'` _into_ each `$or` branch's + recursive call. That flag is meant to decide only how a branch attaches to its + parent builder, but inside the branch it also selected `orWhere` for the + branch's own contents. So a branch's field keys — and the operators of a single + field — OR-ed each other instead of AND-ing: + + | Filter | Compiled to | Should be | + | ----------------------------- | --------------------- | ------------------------ | + | `{$or:[{a:'x', b:'y'}]}` | `a = 'x' OR b = 'y'` | `a = 'x' AND b = 'y'` | + | `{$or:[{d:{$gte:X, $lt:Y}}]}` | `d >= X OR d < Y` | `d >= X AND d < Y` | + | `{$or:[{$and:[A,B]}, {c,d}]}` | `(A AND B) OR c OR d` | `(A AND B) OR (c AND d)` | + + The Filter Protocol rule this breaks is Mongo's: **everything inside one filter + object is AND-ed, at every depth.** A `$or` array OR-s its _branches_; it does + not change how the contents _within_ a branch combine. + + Every miscompile widens the result set, never narrows it, so affected queries + returned **more** rows than the filter allowed. Two shapes to re-check in your + own metadata after upgrading: + + - **Scoping filters** that pair a discriminator with an id list per branch — + `{$or:[{parent_object, parent_id:{$in:[…]}}, …]}` and similar — were not + holding the pairing. Where such a filter decides visibility, it was returning + rows outside the intended scope. + - **Sharing-rule `criteria_json`** containing a `$or` whose branches carry more + than one key (what a "match ANY of these groups" criteria builder emits). That + path _writes_ `sys_record_share` grants, so any over-match materialized + durable grants that outlive this fix — **re-reconcile those rules after + upgrading**; the driver fix alone does not retract grants already written. + + Also affected: the abutting `$gte`/`$lt` window pattern the automation docs and + CLI flow linter recommend for scheduled flows. Each tier degenerated to + `d >= lo OR d < hi`, which matches every row, so multi-tier reminder flows fired + on the whole table instead of one window. + + `driver-sql` was the sole divergent backend — `driver-memory`, + `driver-mongodb`, the analytics `read-scope-sql` compiler and the write-side + `matchesFilterCondition` evaluator all already AND-ed per node. Conformance + tests now pin the same shapes across the three in-repo evaluators so they cannot + drift apart again. `driver-sqlite-wasm` inherits the fix (it extends + `SqlDriver`); Postgres, MySQL, SQLite and sqlite-wasm were all affected. + + The `$and` arm also now honors `logicalOp`, as `$or`/`$not` already did. Nothing + reaches it with `'or'` once the propagation above is fixed, but the two changes + are only correct together — leaving one combinator deaf to the flag is how the + rules drifted apart in the first place. + +- 0e3a226: fix(authz): widen the driver's native tenant scope to the membership union + under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623) + + The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids` + under `group`, but the ObjectQL engine also propagated the active-org + `tenantId` into `DriverOptions` unconditionally, and the SQL driver's native + scoping ANDed `organization_id = tenantId` under the union — collapsing every + group read back to active-org (isolated) reach. Found by the cloud-side + `ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group` + against a real driver. + + - `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with + native scoping widen reads/updates/deletes/aggregates to `IN (...)`, + keeping the NULL-tenant global-row carve-out; inserts still stamp from + `tenantId` (the active organization is the write target, D5). Absent or + empty ⇒ equality fallback — fail toward isolation, never toward exposure. + - ObjectQL engine threads `ExecutionContext.accessible_org_ids` as + `tenantIds` when the tenancy posture is `group`, reported by a new + `setTenancyPostureProvider` seam. + - SecurityPlugin wires that provider at start — deliberately from the + enforcement layer, so the driver wall only widens while the Layer 0 union + wall enforces above it. Embeddings without plugin-security keep active-org + equality. + +- 81ce41a: feat(rest): `treatAsHistorical` import also preserves the original audit timeline (#3493) + + Follow-up to #3479/#3483. `treatAsHistorical` solved the FSM half — mid-lifecycle + rows are no longer rejected by `initialStates` — but the OTHER half of a historical + migration, preserving the original timeline, still didn't hold: an imported ticket + that closed in 2021 stored `updated_at` = the import day (and `updated_by` = the + importer), and a `writeMode: 'upsert'` refresh silently dropped business `readonly` + fields (`closed_at`, `resolved_by`). Reports, audit, and "recently modified" + sorting all came out wrong. + + Three layers were force-overwriting the timeline; all three now respect a single + new opt-in flag, `ExecutionContext.preserveAudit`, which `treatAsHistorical` sets + alongside `skipStateMachine`: + + - **spec**: `ExecutionContext.preserveAudit` (server-set only, never client-supplied) + and `DriverOptions.preserveAudit` (threaded to the driver's update stamp). + - **objectql** — the built-in audit hook (`plugin.ts`) now treats `updated_at` / + `updated_by` as CLIENT-PREFERRED (`?? now` / `?? userId`) under `preserveAudit`, + symmetric with how `created_at` / `created_by` already behave on insert; and the + static-`readonly` write strip (`stripReadonlyFields`) admits a WHITELIST — the + audit/timestamp family plus author-declared business `readonly` fields — so an + upsert refresh no longer drops them. + - **driver-sql** — the SQL `update` path keeps a supplied `updated_at` instead of + force-advancing it to `now` when `DriverOptions.preserveAudit` is set (fills-only- + empty, mirroring the insert stamp). + - **rest** — the import runner sets `preserveAudit` on the write context iff the + request opts into `treatAsHistorical`. + + Deliberately a WHITELIST, not the blanket `isSystem` exemption: platform-managed + `system` columns OUTSIDE the audit family (`organization_id` / tenancy, generated + columns) STAY stripped, so a historical import reinstates established facts without + becoming a backdoor to forge tenancy. Permissions / RLS / field-level security are + unaffected — this changes only which audit/readonly values the runtime overwrites, + never who may write the record. Fully opt-in: a normal write still auto-stamps + `updated_at`/`updated_by` and strips `readonly` exactly as before. The objectui + "Import as historical data" checkbox (objectui#2815) now drives both halves — no new + UI. + +- 647ec8b: fix(driver-sql,sharing): an unsortable query loses its ORDER BY, not its rows (#3821) + + `SqlDriver.find()` already recovered from a SELECT projection naming a column + the table lacks (retry with `select('*')`, the unknown field is simply absent + from each row). The identical failure one clause over — an **ORDER BY** column + the table lacks — fell through to `return []`. Because `count()` is a separate + statement, the list endpoint answered `HTTP 200` with `records: []` and + `total: 3`: the rows are there, none are shown, nothing is logged. Same family + as the `$`-param footgun closed by #2926. + + It surfaced through the Console's sharing-rule **recipient picker**, which + never listed a single candidate. The client mangled `'name asc'` into + `0 n,1 a,2 m,…` (fixed separately in objectui) and the driver turned that into + "no users exist", so no sharing rule could be authored from the UI at all. + + Rows now outrank their order: the retry ladder drops the projection first (the + likelier culprit and the cheaper thing to lose), then the sort, then gives up. + A query that cannot be sorted comes back **unordered instead of empty**. Errors + that are not about an unknown column still propagate untouched. + + **A rule authored in Setup now actually applies — and switching it off actually + withdraws access.** Writing a `sys_sharing_rule` rebound the per-record hooks, + which only makes the rule reach records written FROM THEN ON. So an admin who + created a rule and enabled it saw nothing happen: the recipient's list stayed + empty until somebody happened to touch each record. The reverse was worse — + switching a rule OFF, or deleting it, left every grant it had already issued in + place, and boot backfill only reconciles ACTIVE rules, so those grants outlived + restarts while the UI displayed the rule as disabled. The reconcile was reachable + only through `POST /sharing/rules/:id/evaluate`, which the Console never calls. + + Each non-system write to `sys_sharing_rule` now also reconciles that rule's + grants, chained behind the existing rebind: insert/update run the same + diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is + inactive), and delete purges directly via the new + `SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because + the row is already gone (`RULE_NOT_FOUND`), which is also why a rule deleted + through the plain data API used to orphan its grants. Seeding and package + bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already + backfills those. Reconciliation is best-effort and never fails the write. + + **The dialog's help text was engineering notes, shown to tenant admins.** The + field descriptions on `sys_sharing_rule` render under each input in Setup, and + they cited ADR numbers, table and column names (`parent_business_unit_id`, + `sys_business_unit`), enum machine values the dropdown never shows + (`business_unit`, `team`), a third-party library (better-auth), and engine + vocabulary ("evaluation", "lifecycle"). Several were also stale: they still told + admins to type an id or hand-write a `FilterCondition` after those inputs became + a record picker and a visual builder. Rewritten for the reader who actually sees + them — the implementation detail was already in the object's doc comment, which + is where it stays. `criteria_json`'s LABEL loses its "(FilterCondition JSON)" + suffix for the same reason, and `active` can finally say what it now does: + turning it off withdraws the access. + + Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES + translation bundles, which still described `recipient_type` in terms of + `department` (the enum value is `business_unit`) and told admins to enter a + queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES + option labels for `position` / `unit_and_subordinates` were translated as + "rol" — corrected to "Puesto" / "Unidad de negocio y subordinados". + +- 5f0852f: fix(driver-sql): bucket a SQLite `Field.datetime` by its stored instant instead of collapsing every row into one `(null)` (#3773) + + On SQLite, any trend chart bucketed by day/week/month/year over a + `Field.datetime` column put **every record in a single `(null)` bucket** — one + bar, carrying the whole total. The measure was right; only the bucket key was + wrong. `Field.date` (ISO TEXT storage) was unaffected, so the same dashboard + could show one column working and the next one flat. + + better-sqlite3 stores a `Field.datetime` as INTEGER epoch **milliseconds** (knex + binds a JS `Date` as `.getTime()`), and `buildDateBucketExpr` emitted a flat + `strftime('%Y-%m', col)`. SQLite reads a bare integer as a **Julian day + number**; an epoch-ms value is far outside the legal range, so `strftime` + returned NULL for every row. Nothing downstream noticed: SQLite advertises + `queryDateGranularity.month`, so `engine.aggregate` pushes the bucketing down, + and its in-memory fallback only engages for an _unsupported_ granularity or a + non-UTC timezone. + + The SQLite expression is now storage-aware, sharing one `isEpochStoredDatetime` + predicate with the filter-comparand coercion added for the same root cause in + \#2034 — a window and a bucket that disagree about storage is exactly how an + epoch column ended up correctly filtered and then entirely bucketed as NULL. + Postgres and MySQL are untouched: `defineColumn` maps `Field.datetime` to a + native timestamp there, which is also why their comparands are left alone. + + Two details are load-bearing and pinned by tests: + + - The conversion dispatches on each **stored value's** type, not just the + declared one. A SQLite `Field.datetime` column is genuinely mixed-form — + `formatInput` passes datetime values through, so a `Date` lands as INTEGER + while an ISO string (including an unresolved `defaultValue: 'NOW()'`) lands as + TEXT. Dividing TEXT by 1000 coerces it to its leading year, filing live rows + under 1970 — worse than the NULL it replaced. + - Division is `/1000.0`, not `/1000`. Integer division truncates toward zero, so + a pre-1970 instant (`-1` ms) would surface as 1970-01-01. + + `bucketDateValue` (the in-memory fallback in `@objectstack/objectql`) now reads a + finite **number** as epoch milliseconds. `new Date(String(1767225600000))` is an + Invalid Date, so a driver handing back raw storage values bucketed as `'(null)'` + there while the pushed-down SQL bucketed correctly — fixing only the driver would + have traded one wrong answer for two different ones, and the two paths have to + label the same instant identically for a drill-down to survive crossing them. + + `SqliteWasmDriver` inherits `buildDateBucketExpr`, so it carried the bug and gets + the fix. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index 36e80b1dc6..78c9f6c315 100644 --- a/packages/plugins/driver-sql/package.json +++ b/packages/plugins/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "16.1.0", + "version": "17.0.0-rc.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md b/packages/plugins/driver-sqlite-wasm/CHANGELOG.md index baa6c0bd83..568de7ce58 100644 --- a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/plugins/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,136 @@ # @objectstack/driver-sqlite-wasm +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [cf5e033] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [647ec8b] +- Updated dependencies [7457a09] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/driver-sql@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/plugins/driver-sqlite-wasm/package.json index 1e919bffe9..b93b4f4ebb 100644 --- a/packages/plugins/driver-sqlite-wasm/package.json +++ b/packages/plugins/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "16.1.0", + "version": "17.0.0-rc.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index 6960724e75..cd1063d4e4 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/embedder-openai +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 64a62c331a..e6a868276c 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "16.1.0", + "version": "17.0.0-rc.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 d4851f2447..b39b66dd84 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,129 @@ # @objectstack/knowledge-memory +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/service-knowledge@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 9c00525188..c09bbd4f65 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "16.1.0", + "version": "17.0.0-rc.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 f949c0e54e..12b14503f9 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,129 @@ # @objectstack/knowledge-ragflow +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/service-knowledge@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 6310c68158..6a21cb392f 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "16.1.0", + "version": "17.0.0-rc.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/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 3036ec0c88..7039662a0a 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,1025 @@ # @objectstack/plugin-approvals +## 17.0.0-rc.0 + +### Minor Changes + +- 14252d3: feat(approvals): cross-organization approver targeting — a plant document can + require a group-side sign-off (ADR-0105 D9) + + One organization id used to decide three different things at once in + `openNodeRequest`: where the request row lives, where its inbox index rows + live, and **where its approvers are looked up**. The first two are the + request's own organization by definition. The third is not — a group CFO holds + her `cfo` position in the GROUP organization while the purchase order she signs + off lives in the PLANT organization. `expandPositionUsers('cfo', )` + matched nobody, the slot fell back to the dead `position:cfo` literal, and a + group escalation could not be expressed at all. + + An approver may now declare which organization's directory resolves it: + + ```yaml + approvers: + - { type: position, value: plant_manager, group: plant } + - { type: position, value: cfo, organization: $root, group: finance } + behavior: per_group + ``` + + - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two + common intents need **no deployment knowledge** — flow metadata is portable + across environments while organization ids are minted per deployment. A slug + covers what the symbols cannot, notably a **sibling** organization (a + shared-services centre approving payables for every plant). + - Declared **per approver**, so one node can require a plant manager and a + group CFO in parallel. A node-level form cannot express that without + splitting into serial nodes, which changes the semantics. + - **Bounded, not free:** the target must share a `parent_organization_id` root + with the request's organization. The rule reads only the organization tree — + never the submitter — so one flow routes identically for everyone. + + Everything else fails loudly rather than quietly: + + - a non-`group` posture **refuses** the declaration (a `group` → `isolated` + migration must not silently reroute approvals); + - an approver type with no org-scoped directory (`user` / `field` / `manager` / + `team`) refuses it too, and a new `approval-approver-cross-org-unsupported` + lint catches that at author time; + - a targeted approver holding no membership in the request's organization is + dropped with a warning naming them — D2's union wall would otherwise hide the + request from someone already routed to, so the node's existing + `onEmptyApprovers` policy takes over instead of leaving an unopenable task. + + Nothing changes for an approver without `organization`: same resolution, same + queries, no extra reads. + +- f92096b: fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800) + + Every mutating approvals entrypoint takes an `actorId`, and the REST routes + filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body + won. The service then authorized _that value_: `pending_approvers.includes( +input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never + checked that the value named the caller. + + So any authenticated user could POST `{"actorId": ""}` and have + that person's approval recorded, the request finalized, and the owning flow run + resumed down the `approve` edge — or name a request's submitter and recall it. + With `api.requireAuth` unset the anonymous-deny never fires either, so an + unauthenticated request could do the same. + + #3783 drew this line for the _data-write_ identity and called the audit-row half + "tolerable". It was not: the same unchecked string was the authorization key, so + naming someone else was not a mislabelled audit row, it was how you got through + the door. + + The actor is now resolved server-side (`ApprovalService.resolveActor`) on all + nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`, + `reassign`, `remind`, `requestInfo`, `comment`. + + **The rule is not "`actorId` must equal `context.userId`."** A slot can + legitimately be keyed by something else: the approver resolver stores the + `type:value` literal when a graph lookup finds no holders, and the Console picks + from the caller's own identity list — user id, email, or `role:`. The rule is + **"the actor must be an identity the server can prove belongs to the caller"**: + + - A **system** context keeps its explicit actor. The SLA sweep's reserved + `system:sla` sentinel and the ADR-0043 action link — whose single-use hashed + token binds exactly one approver — are unchanged. They are the only callers + holding a trustworthy actor with no session behind them. + - A caller with **no identity at all** is now refused. This is the anonymous case + above. + - **No `actorId`, or one naming the caller**, resolves to the caller. This is the + common path and what the Console already sends. + - **Any other value** is accepted only when the server can prove the caller holds + it — `position:

` / `role:

` against the positions on the resolved authz + context, or the caller's own email (one lazy `sys_user` read, taken only when + nothing cheaper matched). Otherwise `FORBIDDEN`. + + REST still forwards the body value; it is now a _hint_ the service validates, + which is what keeps the email and `type:value` slot cases working. + + **Upgrade note.** A client that deliberately sent another user's `actorId` now + gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the + acting user's own session — the field can be omitted entirely, and the caller is + used. Server-to-server callers that legitimately act for someone else should + present a system context, as the SLA sweep and the action link already do. + + This also makes two existing claims true that were previously aspirational: the + approval object's declared actions say "`actorId` defaults to the caller + server-side… the service remains the authority on who may act", and + `attachViewers` documents `can_act` as mirroring "the exact authorization the + decision methods enforce". + +- fb90784: fix(approvals): the status mirror names the human who caused the transition (#3783) + + When an approval moves, the service writes the new status onto the business + record (`approvalStatusField`). That write is what fires the record-change flows + bound to that object — so it is the seam "when the invoice is approved, do X" + runs through. It presented a bare `{ isSystem: true }` context with **no + `userId`**, at six call sites that each know exactly who acted: a submitter + submitting, an approver approving, rejecting, sending back, recalling. + + Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger + user touch data — that identity gap made the most natural approvals automation + there is unwritable in its obvious form. The cascade inherited no user, so its + data nodes were refused, and the author's only way forward was to declare + `runAs: 'system'` and take blanket elevation for a case where a perfectly good + scoped identity existed at the call site all along. + + The mirror now carries the acting user. It stays `isSystem` — the record is + normally locked while its approval is live, so only a platform write can land the + status — because elevation and anonymity are separate choices, and this write + only ever needed the first. Cascades now run as the deciding user with RLS + enforced. + + - **The identity is the authenticated principal, never the request body's + `actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`) + and is only checked against the pending approver slate, never against the + caller. That is tolerable on an audit row; promoting it to the identity of an + RLS-scoped write would have turned a mislabelled audit trail into identity + spoofing. + - **Approval-by-email-link is attributed too.** ADR-0043 action links carry no + session, so they used to decide as pure system. The single-use hashed token + binds exactly one approver and is re-checked against the live slate at + redemption — that is an authentication — so the redeemed decision now presents + that approver, and an emailed approval cascades identically to one made in the + UI. + - **The two machine-driven transitions stay user-less on purpose**: the SLA + escalation's auto-decision and the dead-run sweep. `system:sla` and + `system:dead-run` are reserved audit actors, not users, and presenting one as a + user would put a non-user in `updated_by` and in every downstream flow's + identity. A flow that wants to react to those declares `runAs:'system'` — the + honest answer, and now a deliberate one rather than an artefact. + - **Attribution only — the write is not newly org-scoped.** On an + ExecutionContext `tenantId` is a driver-scoping knob, not attribution + (ObjectQL turns it into a tenant predicate), so passing the request's org would + have silently no-op'd the mirror on a record whose org differs. The automation + engine already back-fills a run's `tenantId` from the resolved user's grants. + + **Visible change:** the mirrored record's `updated_by` now names the acting user + instead of retaining its previous value — ObjectQL's audit stamping is gated on + the write context's `userId` alone, and `isSystem` buys no exemption. That is the + attribution this fix is for: the approver who set the record to `approved` is now + its last modifier. + +- a6c3f38: feat(approvals): expose the pending node's `lockRecord` policy on the request row (#3814, objectui#2902) + + An approval node declares `lockRecord` (default `true`), and the record-lock + `beforeUpdate` hook enforces exactly that: `lockRecord: false` and the record + stays writable for the whole time the node waits. The behavior was correct and + has been since Phase B — but it was **invisible to every client**. + + `rowFromRequest` parses `node_config_json` and projects a whitelist out of it + (`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`, + `decisionOutputs`). `lockRecord` was never in that list, and no other field on + `ApprovalRequestRow` carried the lock either. So the strongest thing a console + could learn from `GET /approvals/requests` was _"a pending request exists"_ — + from which it can only assume the record is locked. + + That assumption is wrong on every opted-out node, and a flow that chains nodes + with different policies makes it visibly wrong: the same UI state renders for + "you may edit this" and "the server will reject your save with `RECORD_LOCKED`". + The console has no third option — guessing the other way would offer an edit + that dies on save. + + `ApprovalRequestRow` now carries **`lock_record: boolean`**, read from the same + snapshot the hook reads, with the same `!== false` default. Present on every + service read (`openNodeRequest` / `getRequest` / `listRequests`), so the flag a + client renders and the rule the server applies cannot drift. + + Additive and backward compatible — nothing to migrate. A client that wants + node-accurate lock state reads `request.lock_record`; treat `undefined` (an + older backend) as locked, which is the pre-existing behavior. + + The showcase's `showcase_budget_approval` now declares `lockRecord: false` on + its single-approver Manager Review and keeps `true` on the multi-approver + Executive Review, so both policies are exercised in one flow. + +- d75edb9: Approval nodes now resolve `field` / `manager` approvers against the record's **live** state at node entry, not the trigger snapshot the flow froze at submit time (#3447). An earlier step — or the approver of an earlier step — can now write the field that routes a later step's approvers, enabling dynamic routing / dynamic co-sign (e.g. a lead reviewer picking which departments co-review, then those departments resolving as parallel approvers). Graph approvers (team / position / department / tier) already resolved live; this brings the in-record types into line. + + Also fixes two latent defects on the same path: a multi-select user field now fans out into one approver slot per user (previously the array was stringified to a single bogus id), and out-of-office delegation is applied per fanned-out user (previously silently skipped for multi-value fields). When the record can't be re-read (hard-deleted mid-flow, or a backend that can't serve a point read), resolution falls back to the trigger snapshot and warns rather than wedging the flow. + +- 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801) + + `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied + `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and + `resumeInternal` validated **machine state only** — the concurrent-resume latch, + the run exists, the flow exists, the suspended node still exists. Nothing asked + _who was calling_. + + Approval nodes suspend and resume through exactly that mechanism. So a resume + carrying `branchLabel: 'approve'` walked the approve edge with **no approver + check, no `sys_approval_action` row and no status mirror** — the + `sys_approval_request` row and the run then disagreed permanently. The only + thing standing between the route and the approvals rules was convention; the + showcase spelled it out in a comment ("decide via the approvals API, never a raw + engine `resume`"), and a comment in an example is not an access control. + + Removing the route was not the fix: it is load-bearing for **screen flows** — + the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node. + The gate therefore keys on **what the run is parked on**: + + - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) — + a pausing node declares who may continue it. `approval` declares `'service'`. + - The engine refuses a `'service'` suspension unless the signal carries + `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the + owning service stamps in-process — a JSON body can never produce one, so the + transport cannot forge it. `ApprovalService` stamps it on the tail of a + decision it has already authorized and recorded. + - The gate follows a **subflow** pause down to the child the signal would + actually reach, so resuming the parent is not a way around it. + - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers + **403**. Nothing is consumed — the request stays pending and the run stays + parked, so the real decision still lands. + + `screen` and `wait` pauses are unchanged, as is every path that already went + through the approvals API. What changes for consumers: + + - **FROM:** finishing an approval with + `client.automation.resume(flow, runId, { branchLabel: 'approve' })` + **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`). + The old call now answers 403 and changes nothing. + - Registering your own pausing node whose continuation belongs to a service + rather than to whoever holds the run id? Declare `resumeAuthority: 'service'` + on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that + service. + + A suspension now records the node type that produced it + (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend + time so a flow republished mid-pause cannot re-type the node out from under the + gate; rows written before this fall back to the flow definition. + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +- 57bab76: Typed `decisionOutputs` declarations (#3447 follow-up). A `decisionOutputs` entry may now be `{ key, label?, type: 'text' | 'user' | 'department' | 'position' | 'team', multiple? }` alongside the bare-string form — a typed entry tells the decision UI to render the matching record picker (id values; `multiple` collects an id array) instead of free text, turning "paste user ids" into "pick people". The type shapes only the input widget: the runtime whitelist works by `key` either way, via the new `normalizeDecisionOutputs` helper exported from `@objectstack/spec/automation` — the single reader of the union shape shared by the service, the request read, and `os lint`. The request read now carries `decision_output_defs` (normalized declarations) alongside the version-skew-safe `decision_outputs` key list. + +### Patch Changes + +- d058594: fix(approvals): refuse `organization` on directory-less approver types instead + of silently ignoring it (ADR-0105 D9) + + `user`, `field` and `manager` return EARLY in `resolveApproverSpec` — they name + a person outright rather than expanding a directory. D9's org resolution was + placed after those returns, so an `organization` declared on one of them never + reached the check: it was silently INERT. + + That is the one behaviour ADR-0105 D9 rules out and the authoring docs + explicitly promise against ("`organization` on those is refused at runtime"). + The `os lint` rule caught it at author time, but the runtime claim was false — + and a stored flow that predates the lint, or one assembled programmatically, + got no signal at all. + + Resolution now happens at the top of `resolveApproverSpec`, above every early + return, so the refusal reaches all three types. The ordinary path is unchanged + and still costs nothing: with no `organization` declared the resolver returns + the request's organization without reading anything. + + Found by cloud's group-posture dogfood driving a real `group` boot — the + resolver's own unit tests could not see it, because they call the resolver + directly and never traverse the early return. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 2ba560a: fix(plugin-approvals): give the decision actions a visual hierarchy (objectui#2762 P1-5) + + The `sys_approval_request` decision actions all declared as equal-weight + buttons, so the drawer's action bar rendered five identical outlined + buttons with no emphasis on the primary path. `approval_approve` now + declares `variant: 'primary'` and `approval_reject` declares + `variant: 'danger'`, so a metadata-driven renderer highlights Approve and + styles Reject as destructive — matching the hierarchy the mobile card + already has. Pure metadata; the secondary levers stay unstyled (tertiary). + +- 2dda6e7: fix(plugin-approvals): localize the declared decision-action labels (objectui#2762 P0-3) + + The Approval Center's decision drawer rendered the `sys_approval_request` + declared actions with their literal metadata labels — English **Approve / + Reject / Reassign / Send back / Request info** in a zh-CN workspace, sitting + next to the same page's localized 通过 / 拒绝 inbox buttons. The plugin's + translation bundle covered fields and views but had no `_actions` node, so + the console's `_actions..label` resolution had nothing to hit. + + - Re-ran `os i18n extract` against the plugin's config: the bundles now carry + `_actions` translations (label, confirmText, successMessage, param labels + and helpText) for all eight decision actions — `approval_approve`, + `approval_reject`, `approval_reassign`, `approval_send_back`, + `approval_request_info`, `approval_remind`, `approval_recall`, + `approval_resubmit` — in zh-CN, ja-JP and es-ES (en keeps the metadata + literals). + - The extract also surfaced other untranslated gaps, now filled in all three + locales: the `returned` status option, the `sys_approval_action.action` + audit options (`reassign` / `remind` / `request_info` / `comment` / + `revise` / `resubmit` / `ooo_substitute`), the `attachments` field, and the + `my_pending` / `recent` view empty states. + +- 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508) + + - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a + designer must source each approver row's `value`: `user`/`team`/`department`/`position` + are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` / + `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the + others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`), + `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is + unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`. + - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep + loading and rendering) but is published in `xEnumDeprecated`, so designers stop + offering it — the runtime has no queue resolution and the slot routes to nobody. The + approver `value` xRef now also maps `manager`, so designers can render its + auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries + `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete + `user`) until a real ownership-queue implementation lands. + - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue` + approver is skipped. + - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver + types that are declared but not implemented by the runtime. + +- 0bc685a: fix(approvals): return decision attachments as file values, not "[object Object]" (#3504) + + `sys_approval_action.attachments` is a `Field.file`, so the column **stores an + opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The + ObjectQL read path resolves that id into its expanded + `{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction` + mapped the column with `.map(String)`, collapsing each expanded value to the + literal string `"[object Object]"`. Every `listActions` consumer (the approval + inbox timeline) then received garbage: the attachment chip had no filename and + its id was `"[object Object]"`, so opening it 404'd. + + - `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the + expanded file value plus its id, so a consumer can label and open an + attachment without needing read access to the system `sys_file` object (which + regular approvers do not have). + - Three read forms are accepted: the expanded value (the normal case), a bare id + (nothing to expand it into — storage service absent, file not committed), and + a legacy inline blob written before file-as-reference (`file_id` / + `mime_type`), until the backfill converts it. The id test reuses the + platform's `isFileIdToken`, so this and the engine's read resolver cannot + disagree about what counts as an id. + - The decision _input_ (`ApprovalDecisionInput.attachments`) is unchanged — it + still takes fileId strings, which is also exactly what the column stores. Only + the read shape changed. + +- b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) + + The record lock is keyed on a **pending** `sys_approval_request`, and it could + not tell _the run that owns that request_ from _an unrelated user editing the + record_. So a flow that touched its own target record while its own approval was + still pending — a manual `resume` with no decision, or a node that writes the + record between opening the approval and the decision — died on its own + `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery + existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it + self-healing. + + Both halves are now closed. + + **Prevention — the owning run may write its own record.** The automation engine + stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it + travels with every data node's ObjectQL context into `ctx.provenance`. The lock + hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`. + It is keyed on run identity rather than elevation on purpose: a `runAs:'user'` + run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance — + server-constructed like `isSystem`, never client-supplied, evaluated by no + security middleware, and the only write it permits is to the one record its own + run already holds a pending request against. + + **Recovery — a sweep releases records held by runs that died anyway.** A pending + request whose owning run has reached a terminal state (`completed`, `failed`, + `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled` + — releasing the lock — and audited under the reserved actor `system:dead-run` + with the run and its status in the comment, so it is never mistaken for a + submitter's withdrawal. It runs on the existing approvals sweep clock, which also + covers the case no in-band handler can: a run killed by a process crash. + + The sweep is fail-safe by construction. It acts only on an explicit terminal + status from a closed set; `paused` (the normal state of a live approval), + `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a + deployment with no automation engine are all read as "still alive". The failure + mode is "a dead run's lock survives until an admin recalls it" — today's + behaviour — never "a live approval is destroyed". + + Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for + a run id rather than the latest. A run that pauses and later finishes records two + entries under one id, so every suspend-then-finish run — every approval, screen + and wait flow — reported itself as `paused` forever, both on the Runs + observability surface and to this sweep. + + One shape was left out here and closed separately in #3712: a `runAs:'user'` run + with no trigger user (a schedule) resolved no ObjectQL context at all, so it + carried no `flowRunId` and stayed subject to the lock. It now passes a + provenance-only context — the run id and nothing the security middleware keys on + — so it is attributable without acquiring a principal, and its documented + unscoped posture (#1888) is unchanged. + +- be1c52c: fix(approvals): admin override for a request routed to an unstaffed approver (#3424) + + An `approval` node routed to a `position` (or `team`/`department`) with **no + holders** resolved to only the unresolvable `position:` literal in + `pending_approvers` — no concrete user was in the slate. Every normal + `decide` / `reassign` / `recall` then returned `FORBIDDEN` (not a pending + approver) and, with `lockRecord`, the target record stayed `RECORD_LOCKED` + forever: a data-availability dead-end with no in-product recovery (the only exit + was editing the DB by hand). Very easy to hit in fresh/demo orgs (positions + seeded, holders not) and whenever a role is vacated in production. + + A **platform or tenant admin** — the same posture the engine's superuser bypass + already trusts — may now act on any _pending_ request to release it: **approve, + reject, reassign** it to a real approver, or **recall** it. The override finalizes + the request (which releases the record lock, keyed on a pending request); a + tenant admin's authority is org-scoped, a platform admin's is not, and the + decision is audited under the admin's own id. An admin approval is authoritative, + finalizing the node even under `unanimous` / `quorum` / `per_group` rather than + counting as one vote among the (empty) slate. + + - `sys_approval_request.viewer` gains `can_override` (server-computed): true for a + privileged admin on a pending request. The `approve` / `reject` / `reassign` + declared actions OR it into their `visible` gate, so the console surfaces the + recovery path without a hand-wired button. Existing approver/submitter gating is + unchanged. + - `openNodeRequest` now logs a loud warning when a node resolves to **no concrete + approver**, so the misconfiguration is visible instead of silently locking the + record. The literal-fallback behavior (kept for 15.x slot back-compat) is + otherwise unchanged. + +- c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712) + + #3456 let the run that opened a pending approval write its own target record, + keyed on `flowRunId`. It worked for every run that resolves an identity and + missed the one that doesn't: an effective `runAs:'user'` run with **no trigger + user** — a schedule being the canonical case — passed no ObjectQL context at + all, so nothing carried the run id and the run still died on its own + `RECORD_LOCKED`. + + The blocker was never the lock. It was that "no identity" and "no context" were + the same thing on the wire, so a run could not say _who it was_ without also + claiming _what it was allowed to do_. + + **A run with no principal now passes provenance alone.** + `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`, + no `permissions`, not even `isSystem: false`. Every principal gate keys on one + of those fields (the elevation short-circuit on `isSystem`, the ADR-0103 + engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`, + the empty-principal fall-open on all three), so this context authorizes + **identically to no context at all**. The run keeps the documented #1888 + unscoped posture, its loud `[runAs]` warning, and the + `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch + changed — only that it can now be attributed. + + **Provenance moved out of the hook session, into `ctx.provenance`.** `session` + answers _who is calling_ and is absent when no identity envelope was supplied — + a distinction real gates depend on (the attachment access gate skips bare-kernel + writes on exactly that test). Folding a run id into `session` would have forced + an identity-less run to present an empty session, silently turning "no caller" + into "an anonymous caller" and narrowing the #1888 fail-open for attachments + alone. `HookContext.provenance.flowRunId` says what produced the write; the + approvals lock reads it there. + + Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope + (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time + defaults, which made them _required_ on a caller-supplied option and asserted + something untrue — that every data-engine context carries a principal. Callers + have always passed slices (`{ isSystem: true }` for a system read); the type now + says so. + + Migration: nothing to change unless you read the run id inside a hook. If you + wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the + field never shipped under the old name. + +- d2a8695: fix(approvals)!: an approval request is visible to its participants, not to the whole tenant (#3590) + + `getRequest` / `listRequests` / `countRequests` deliberately query with + `SYSTEM_CTX` to bypass RLS — as the code comments say, the approver-visibility + rule spans identity forms RLS cannot model cleanly, so it has to be expressed in + the service. Only the **tenant** half of that rule was ever applied. The + participant half was named in the comment and never written, so **any + authenticated user could read any approval request in their tenant** — its + payload snapshot, its full decision history, and (once decision attachments + derived their access from the request, #3580) its files. + + `approverId` on `listRequests` is a _filter_, not authorization: omitting it + returned the whole tenant. + + A caller now sees a request when they are a participant — the submitter, a + current approver (via the normalized approver index, so every identity form the + write path recorded is covered), or someone who has already acted on it (a past + approver whose slot has moved on, a commenter). Admins with override authority + keep the unrestricted view the "all requests" console surface depends on, and a + tokenless context sees nothing. + + Keying on the concrete user id is sufficient rather than an approximation: + position/team/manager/field approvers are resolved to concrete user ids at open + time, and the `type:value` literal is only the fallback for a spec that resolved + to _nobody_ — a slot no one can act on either way. So this cannot hide a request + from someone who could actually act on it. + + **A write path's own result is not re-gated.** Every operation echoes back the + request it just changed; the operation already authorized itself, and re-asking + would answer wrong for a context carrying no `userId` (a flow-driven resume, a + service-to-service call), turning a successful write into `null`. + + Marked breaking because a client that listed requests without an `approverId` + filter and expected the whole tenant will now receive only its own — which is + the point. + +- 84e7be9: feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807) + + `per_group` (会签) requests now carry `pending_approver_groups` on the + enriched row — a map from each still-pending approver id to the group key(s) + it fills (e.g. `{ "u_devadmin": ["finance", "legal"] }`). A client can label + each "waiting on" chip with the group it represents instead of showing + duplicate, context-free names. + + - Resolved in `attachDecisionProgress` from the same open-time + `__approverGroups` snapshot the `decision_progress` groups already use, so + the two never disagree. + - Only the **pending** slots are mapped (a resolved approver has left + `pending_approvers`), and **synthetic** (unnamed, `#N`) group keys are + dropped — a `· #0` sub-tag would be noise. + - Absent for non-`per_group` behaviors. Display-only; the engine's + finalization tally stays authoritative. + - Added to the `ApprovalRequestRow` contract in `@objectstack/spec`. + +- debc23a: feat(approvals): enrich inbox rows with `payload_labels` (snapshot field labels) + + The approvals inbox summary title-cased raw snapshot machine keys + (`assessment_status` → "Assessment Status") because the API sent no field + labels. `ApprovalService.enrichRows` now attaches `payload_labels` (snapshot + field key → the target object's field label), symmetric with the existing + `payload_display` (which resolves the values), and `ApprovalRequestRow` gains + the field. For a single-locale project the schema label is already the + localized string, so a client can render the human field name (e.g. "考核状态") + instead of a prettified English key. + +- 0f8ad09: feat(spec)+fix(approvals): publish approver value data sources, order the type enum for authors, stop silent dead approver slots (#3508 / #3807 follow-ups) + + Four follow-ups from browser-verifying the #3508 approver work end to end. + + **`APPROVER_VALUE_SOURCES` — the designer stops guessing where candidates live.** + `xRef.map` only ever named a picker KIND (`'team'`), never where that picker's + rows come from, so the designer carried its own copy of the data contract — and + the first copy was wrong: every directory kind was wired to `GET +/api/v1/meta/:type`, the metadata REGISTRY, which does not hold `sys_user` / + `sys_team` / `sys_business_unit` / `sys_position` rows. Candidates came back + empty and the control degraded to free text (#3508). The binding is now + projected onto the published JSON schema as `xRef.sources` — `{ source: 'data', +object, valueField }` for the record-backed kinds, the closed enum inline for + `org_membership_level` — derived from `APPROVER_VALUE_BINDINGS` so the two + cannot drift, and inheriting its `satisfies` exhaustiveness (a new + `ApproverType` member that declares no source is a compile error). Presentation + — which field to show, whether to open a people-picker, what subtitle to use — + stays a renderer decision. + + **`ApproverType` declaration order is now the authoring recommendation.** + objectui#2834 argued for leading with indirect bindings and shipped that order + in its own options array — which the Studio inspector never reads: it derives + the picker from this enum via the published schema, so `user` still came first. + The intent only takes effect if the enum carries it, so the enum now reads + `manager, position, department, team, field, expression, org_membership_level, +user` (deprecated `role` / `queue` still parse and stay out of every picker via + `xEnumDeprecated`). Binding one specific person is the least portable choice an + author can make — it breaks when the flow moves to another environment (that id + does not exist there) and again when that person leaves. + + **A graph approver that expands to nobody no longer does it in silence.** + `queue` already warned (#3508); every OTHER graph type — `team`, `department`, + `position`, `org_membership_level`, `manager` — fell back to the same + unactionable `type:value` literal without a word. That silence is what let + #3807 hide for as long as it did: the request opened with an empty slate and + the first symptom was a permanently stuck approval (#3424). The fallback stays + (15.x slots and substring fixtures depend on it); it now logs the type, value + and organization that produced it. `user` / `field` stay quiet — they take the + id they were given and never had an "expanded to nobody" state. + + **`plugin-sharing`'s identical org scope is pinned by tests.** + `BusinessUnitGraphService.orgScope` has the same strict `organization_id` + equality #3807 fixed in approvals. It is unreachable today — every materialized + `sys_sharing_rule` carries `organization_id = null`, so the filter is skipped — + and widening an authorization path on a defect that cannot currently fire is + not a change to make blind. New tests lock both the reachable paths and the + divergence itself, so if sharing ever adopts the null-org=env-wide reading it + is a deliberate edit to a named test rather than a silent behaviour change. + +- 376a061: Surface the approval node's author-declared `decisionOutputs` keys on the request read as `ApprovalRequestRow.decision_outputs` (#3447 P2 UI enablement). The set varies per request (each node declares its own), so it rides the row rather than the object's static action params — a decision UI renders one input per key and POSTs `outputs` with the decision. +- 3ea7271: fix(approvals): a `department` approver resolves against env-wide business units (#3807) + + `expandBusinessUnitUsers` scoped its `sys_business_unit` reads with a strict + `organization_id = ` equality, so a unit whose `organization_id` + is `null` was invisible: the seed check found no row, the expansion returned + `[]`, and the approver fell back to the dead `department:` literal that + routes to nobody. + + That is the normal case, not an edge case. An app's org tree is seeded, and a + seed cannot know the organization id the runtime mints at boot, so every seeded + unit carries `organization_id = null` — while an approval request always + carries an org. Every business unit a flow author could pick therefore resolved + to nobody, silently: the request opens, the slate is empty, and (with + `lockRecord`) the record stays locked with no one able to act (#3424 is the + downstream shape of the same dead end). Verified against a live showcase stack: + a `{ type: 'department', value: 'bu_hq_finance' }` approver produced + `pending_approvers: "department:bu_hq_finance"` while the unit's member sat + right there in `sys_business_unit_member`. + + Both the seed check and the subtree descent now scope to **this org ∪ + env-wide** — `$or: [{ organization_id: }, { organization_id: null }]` — + the same predicate `sys_metadata`'s pending-draft listing settled on for the + identical reason (a strict equality silently dropping env-wide rows). The wall + between two organizations is unchanged: another org's unit still fails the + match, and a null-org parent does not drag another org's child unit into the + subtree. + + Note the same strict-equality scope exists in `plugin-sharing`'s + `BusinessUnitGraphService.orgScope`. It is not reachable today — every + materialized `sys_sharing_rule` row carries `organization_id = null`, so the + filter is skipped — and is left alone here rather than widen an + access-granting path on a defect that cannot currently fire. + +- deb538f: fix(storage): let an object delegate file-read authorization to its service + + Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a + **legitimate approver could see a decision attachment's filename but got 403 + opening it**, found by driving app-showcase in a browser as a real non-admin + approver. + + Cause: a field-owned file's download was authorized by testing whether the + caller can READ the owning row. For an ordinary business object that is right — + row readability _is_ the access rule. For `sys_approval_action` it is the wrong + authority: the audit table is deliberately closed to ordinary approver + positions (`operation 'find' … is not permitted for positions [auditor, +everyone]`), so the test denied the very approver the attachment was filed for. + The approvals _service_ has always had the real rule, which is why the timeline + listing the attachment returned 200 while the bytes returned 403. + + An object may now name a service to answer the question instead: + + - `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes + downloads of files owned by that object's media fields. + - `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract. + - `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead` + reuses the _same_ gate `listActions` applies (visibility of the parent + request) rather than inventing a second, looser rule for the bytes. + + **Fails closed**: a declared delegate that is missing or does not implement the + method denies, rather than silently reverting to the raw read it was declared to + replace. Objects without the declaration are unchanged. + + Verified in the browser against app-showcase, both sides of the gate: the + approver now downloads the real PDF (200), and an anonymous request is still + refused (401) — the anonymous capability URL the original change closed stays + closed. A decision attachment ends up exactly as readable as the decision it + hangs off: never more, and no longer less. + +- db48ad5: fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026) + + The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch + request is admitted only when the object grants the `bulk` **primitive** and the + batched child operation is itself allowed. Before that, the `*Many` routes + checked only the child verb, so a boilerplate CRUD-five whitelist + (`['get','list','create','update','delete']`) batched fine. + + The companion fix — adding the `bulk` primitive wherever an explicit whitelist + survived — was applied only inside `platform-objects`. Eight objects carrying + the same boilerplate live in other packages and kept the gap, so `/batch`, + `createMany`, `updateMany` and `deleteMany` answered `405 +OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/ + delete were wide open. `data-objectstack` rethrows that 405 without falling back + to per-row writes, which surfaced as a hard error on multi-select delete in the + Setup grids. + + Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`): + `sys_capability`, `sys_permission_set`, `sys_position`, + `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position` + (plugin-security); `sys_approval_delegation` (plugin-approvals); + `sys_view_definition` (metadata-core). + + No new authority is granted: `bulk` only permits batching verbs each object + already exposes one record at a time, and every batched row still passes the + same row- and field-level permission checks. The whitelists stay explicit rather + than being deleted — seven of the eight are `managedBy`, and + `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array + `apiMethods`, so dropping the line would silently disable the managed-write + backstop. + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 653d09e273..697a39cd38 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "16.1.0", + "version": "17.0.0-rc.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 9e1474446a..2f8293dad7 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,228 @@ # @objectstack/plugin-audit +## 17.0.0-rc.0 + +### Minor Changes + +- f243727: remove(plugin-audit): drop the kernel's built-in assignment notifications; move the policy to user-space automation (#3403) + + **Breaking (behavioral).** `plugin-audit` no longer emits a `collab.assignment` + notification when an owner/assignee field changes on a record. Deciding that an + assignment warrants a bell is a business policy, not a platform default — the + kernel version guessed "who is the assignee" from field names (`owner_id`, + `assigned_to`, `assignee_id`, `owner`, `assignee`), which misfired on system + records like `sys_file` and spammed users with "…assigned to you" noise on file + uploads (#3402). + + **What was removed:** the `writeAssignmentNotifications` writer, the `OWNER_FIELDS` + heuristic, and the `messages.assignedToYou` translation key (en / zh-CN / ja-JP / + es-ES). **Unaffected:** `sys_audit_log` / `sys_activity` capture, and `@mention` + notifications (`collab.mention`) — those remain platform behavior. The + `owner_of:` messaging audience and `service-messaging`'s `DEFAULT_OWNER_FIELDS` + are a separate, caller-requested mechanism and are unchanged. + + **FROM → TO migration.** If you relied on the automatic bell, configure an + automation flow on the target object (`record-after-update` / `record-after-create` + trigger + a `notify` node). The `condition` can read the pre-update row via + `previous`, and `notify`'s `recipients` / `title` / `actionUrl` all interpolate + record fields. Ready-made example: `showcase_task_assigned_notify` in + `examples/app-showcase/src/automation/flows/index.ts`: + + ```ts + { id: 'start', type: 'start', config: { + objectName: 'your_object', + triggerType: 'record-after-update', + condition: 'assignee != previous.assignee', + } }, + { id: 'notify_assignee', type: 'notify', config: { + topic: 'task.assigned', + recipients: ['{record.assignee}'], + channels: ['inbox'], + title: 'New assignment: {record.title}', + actionUrl: '/your_object/{record.id}', + } }, + ``` + + Notes on parity: the flow template renders a single language (the kernel version + localized the title to the recipient's locale); a flow fires on every real change + (the `previous` condition already gates that) and, unless you add an actor guard, + also notifies self-assignments — the kernel version suppressed those. + +### Patch Changes + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 80b904a7bb..8c3e30bc27 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "16.1.0", + "version": "17.0.0-rc.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 70a09bd814..1823d07c6c 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,790 @@ # Changelog +## 17.0.0-rc.0 + +### Major Changes + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +### Minor Changes + +- 1bd5652: feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the + `delegated_admin` org role, capped so it cannot mint authority (#3697) + + D8 authorizes invitation _placement_ against the issuer's `adminScope` + (ADR-0090 D12), so a delegated plant admin may invite only into their own + subtree. That gate is implemented, unit-proven and reachable — but no principal + could reach it in a state where it did anything: + + - better-auth grants `invitation: ["create"]` to `owner` and `admin` only + (`memberAc` holds `invitation: []`, which every other registered role + inherits); + - under a wall-enforcing posture, owners and admins are auto-elevated to + `organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard + `modifyAllRecords` that makes `isTenantAdmin()` true — and the gate + short-circuits on tenant admins. + + The two sets were disjoint. Issuance placement was bounded by the Layer 0 org + wall (real, and correct) but never by `adminScope`, so D8's motivating story — + "a plant admin invites into their own subtree without a platform admin + finishing the job" — could not happen. + + **Two pieces, and they only ship together.** + + **1. The role.** `delegated_admin` is now registered with the organization + plugin as `memberAc.statements` plus `invitation: ["create"]` — the one + membership grade that may reach `/organization/invite-member` without being an + org admin. Deliberately _not_ `invitation: ["cancel"]`: better-auth's cancel + route checks the permission with no inviterId attribution, so it would mean + "cancel anyone's pending invitation in the org". + + The role carries no ObjectStack authority by construction — `mapMembershipRole` + passes it through as a position name, and with no `sys_position_permission_set` + binding that name resolves to nothing. Role = _can reach the endpoint_; + `adminScope` = _what the endpoint permits_. + + `sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a + fourth option. Those selects are **enforced on write** — better-auth's own + invitation and membership inserts are validated like any other row — so + registering the role with the org plugin without listing it in both would have + produced a role nobody could hold and nobody could hand out + (`ValidationError: role must be one of: owner, admin, member`). That is exactly + how the end-to-end regression caught it, twice; neither unit test could. The + three non-English translation bundles carry the English label for the new option + until localized. + + **2. The role cap**, in the framework's own `beforeCreateInvitation` hook, + beside the D8 placement gate. Registering the role alone would have been a + four-step privilege escalation: better-auth's only role-level cap on _what role + you may invite someone as_ is its `creatorRole` check (default `owner`), which + blocks inviting an **owner** but not an **admin** — and an accepted `admin` + membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A + subtree-scoped delegate could have manufactured a tenant admin, with every + existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the + acceptance-time membership write runs under better-auth's context, not the + issuer's). + + The cap refuses an invitation whose role outranks the issuer's own, and + restricts a below-admin issuer to plain `member` — not merely "not admin/owner", + because an app-registered role projects into `current_user.positions` and may be + bound to permission sets, making it a capability channel too. A delegate's + channel for capability is the invitation's _placement_ intent, which the D12 + gate allowlists position-by-position. The cap applies to every invitation, + placement-carrying or not (the escalation is independent of placement), and + fails closed: an issuer role that cannot be resolved confers nothing above a + plain member. + + **What changes for deployments.** One new class of principal exists: members + holding the `delegated_admin` org role, who can invite into the org — as + `member` only, into the subtree their `adminScope` allows. It is opt-in twice + over (someone must set the membership role _and_ grant an adminScope set), so a + default deployment changes not at all. Org owners and admins are unaffected. + + Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so + console and control-plane surfaces name the role from one place. + +- 7fb436c: Multi-organization operation is an ENTITLEMENT again: the `group` posture no + longer activates without the enterprise runtime (ADR-0105 D12 correction). + + The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the + posture self-activating — it never probed for `@objectstack/organizations`. That + turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 + D2), and made the weaker isolation the free one, which is not a boundary anyone + would draw on purpose. + + The distinction that was missed: **open code is not free activation.** The wall's + implementation has always lived in the open packages — that is equally true of + `isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a + service the enterprise package registers. Cloud ADR-0016's 铁律 + (强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is + safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture + away. + + ## Changes + + - **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`. + Without it the posture resolves to `single` and reports `degraded`. + - **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE + instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped + both the enterprise package load AND the fail-fast, silently degrading to an + unwalled deployment — the exact ADR-0049 class that guard exists to close. A + `group` request without the runtime now refuses to boot unless + `OS_ALLOW_DEGRADED_TENANCY=1`. + - **New seam — the runtime declares what it entitles.** `org-scoping` may expose + `supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`); + the open side honours it and fails closed on anything not listed. Whether + `group` and `isolated` are one commercial tier or two is packaging policy, and + packaging policy belongs to the commercial runtime rather than hard-coded in + open core. Omitting the field entitles every walled posture, so existing + runtimes are unaffected. + - **`organization_id` stamping returns to the enterprise runtime.** The previous + wave moved auto-stamping into the open engine; that removed the closed + package's only load-bearing runtime duty, so a five-line forged `org-scoping` + registration would have produced a fully working multi-org deployment. With + stamping back where it was, a forged registration yields NULL-org rows the wall + hides — a broken deployment, not an unlicensed working one. + + **Write-side VALIDATION stays open and is unchanged**, including the + bulk-insert coverage: rejecting a forged `organization_id` is a security + property, not a packaging one. Only filling an ABSENT value moved back. + + - Default-organization bootstrap returns to `single`-only; every walled posture + keeps its existing owner (ADR-0081 D1). + + ## Note for operators + + `OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now + **refuses to boot** rather than running single-org. This only affects + deployments that adopted `group` between the two waves. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 313d7be: feat(auth): `onInvitationAccepted` host seam — better-auth's + `afterAcceptInvitation` forwarded to the host (ADR-0105 D8 prerequisite) + + An invitation may carry placement intent (target business unit + positions, + extension fields on `sys_invitation` per the ADR-0092 whitelist), but there + was no server-side seam to apply it when the invitation is accepted — + better-auth's org-plugin models don't fire core `databaseHooks` (framework + #3541 D8 note). + + `AuthManagerConfig.onInvitationAccepted` mirrors `onOrganizationCreated`: + invoked from `organizationHooks.afterAcceptInvitation` with the mapped ids + (`invitationId`, `organizationId`, `userId`, `memberId`, `role`, `email`) + plus the RAW `invitation` / `member` rows so a host reads its own extension + columns without a second query. Failure-isolated — acceptance never rolls + back on a side-effect miss; hosts needing effectively-atomic placement + should make the callback idempotent and reconcile on retry. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- aa8b847: feat(authz): scoped invitations — placement intent on an invitation, gated by + the issuer's adminScope and applied on acceptance (ADR-0105 D8) + + An invitation may now carry PLACEMENT INTENT — the business unit the invitee + lands in and the positions they are assigned — so a delegated (plant) admin's + invitee arrives already in the right unit and role instead of waiting on a + platform admin. This closes the structural gap ADR-0105 D8 names for + `single`-posture deployments and is the natural admission path under `group`. + + The two halves ship together, deliberately: + + - **Issuance is authorized** against the ISSUER's `adminScope` (ADR-0090 D12), + by dry-running the existing `DelegatedAdminGate` against the very + `sys_user_position` rows the acceptance would write. The gate is reused + verbatim — no second copy of the subtree/allowlist logic to drift — so an + invitation can never place what its issuer could not have assigned directly. + Without that gate the feature would be an escalation hole: the built-in + `organization_admin` is deliberately read-only on the RBAC tables precisely + so a fresh org admin cannot rebind themselves, and applying an unchecked + invitation payload under system context would hand that authority straight + back. + - **Acceptance applies it**, idempotently and failure-isolated: a replayed + acceptance converges instead of duplicating assignments, and a placement + miss never undoes a valid membership. + + Surface: + + - `sys_invitation` gains `business_unit_id` + `positions` (ADR-0092 extension + fields, registered in the D7 collision-guarded whitelist; NOT generically + editable — placement is set only at issuance, through the gate). + - `@objectstack/plugin-security` registers the `invitation-placement` service + (`assertIssuable` / `apply`). + - `@objectstack/plugin-auth` wires better-auth's `beforeCreateInvitation` / + `afterAcceptInvitation` to it. **Fail closed**: an invitation that requests + placement in a deployment without the delegated-administration runtime is + refused, never silently placed unchecked. + + Existing invitations are unaffected — an invitation without placement intent + never consults the gate and behaves exactly as before. + +### Patch Changes + +- 735f850: fix(security): resolve the ISSUER's real grants when authorizing invitation + placement (ADR-0105 D8) + + Scoped-invitation issuance dry-runs `DelegatedAdminGate` against the + `sys_user_position` rows the acceptance would write. The gate reads authority + off `context.positions` / `context.permissions` — but the invitation hook + handed it a hand-built `{ userId, tenantId }`, which carries neither. Every + delegated administrator therefore resolved to the additive baseline alone and + was refused: + + > requires tenant-level administration or a delegated adminScope (ADR-0090 D12) + + Fail-closed, but dead: only a tenant admin could ever issue a placement, which + is the one case the feature was not for. Caught by cloud's group-posture + dogfood, which exercises the real HTTP path with a real delegate. + + `assertIssuable` now takes `actorUserId` instead of a caller-built + `actorContext` and resolves that user's grants itself through the single authz + resolver (`@objectstack/core` `resolveUserAuthzGrants`) — the same envelope a + transport would have carried, from the same reads. There is no request to + resolve a context from inside a better-auth hook, so the id is what the caller + can honestly supply and the resolution belongs behind the boundary. + + A principal-less call still reaches the gate with an empty context on purpose: + the gate owns that refusal too, so the security boundary keeps exactly one + place an issuance can be denied. + +- 984396b: test(plugin-auth): enumerate better-auth's route table — the `/auth/**` wildcard becomes 55 exact rows (#3656) + + The widest hole the #3642 capstone measured. That guard reports how many SDK + calls match only a `**` prefix family rather than a resolvable route, and the + answer was 60 of ~196 — with 54 on `* /auth/**`, the largest and most + security-relevant namespace in the client. `auth.me` builds + `/api/v1/auth/get-session`; a prefix claim cannot tell you better-auth still + calls it that, and better-auth is a third-party dependency on its own release + cadence (this repo already chased its 1.7 column drift in #3624 / #3647). + + `plugin-auth` mounts it with a single catch-all, so there are no per-route + registration calls to capture the way tranche 3 captured + `registerStorageRoutes`. The seam is `auth.api`: every better-auth endpoint + carries `.path` and `.options.method`, so a live instance is the route table. + + `auth-route-ledger.ts` reads it, in two halves checked differently on purpose: + + - **55 reviewed rows** — every route the SDK calls, each naming its client + method, checked strictly against the live table. This is the rename detector. + - **129-path mounted-surface inventory** — checked for exact equality both + ways, so a version bump that adds publicly-mounted auth endpoints becomes a + reviewable CI diff. Machine-maintained rather than reviewed prose: demanding + a rationale for all 129 would make every better-auth upgrade a hundred-row + review and the ledger would rot into rubber-stamping. + + Enumeration is config-dependent, so the inventory is pinned at the + configuration enabling every plugin the SDK targets — the maximal surface — + with the participating `OS_*` env vars cleared so a developer's shell cannot + produce a spurious diff. Mutation-checked: renaming a ledgered route fails the + suite naming it. + + The capstone guard now includes this ledger in its union and prefers exact rows + over wildcard families when matching — without that ordering fix every + `/auth/*` URL would still have been absorbed by `* /auth/**` and the new ledger + would have changed nothing. Wildcard-only matches fall **60 → 3**; the ratchet + moves with them. What remains is `* /ai/**`, whose routes `service-ai` builds + at plugin start. + + No runtime change: a ledger, a guard, and the header/audit-doc notes. + +- d0fea33: fix(auth): map ObjectQL `ValidationError` to a 4xx on the better-auth paths (#3398) + + A field-level validation failure raised by the ObjectQL record-validator + (e.g. an invalid `image` on `POST /api/v1/auth/update-user`) surfaced to the + HTTP client as a **raw 500 with an empty body**. better-auth only maps its own + `APIError`s to structured responses; any other error thrown from an adapter + method propagates to better-call's router as an unhandled fault → `500 {}`. + + Added the auth-path analogue of the REST layer's `mapDataError`: the objectql + adapter now detects the ObjectQL validation envelope at its boundary (duck-typed + by `code` / `name`, so plugin-auth keeps no hard dependency on + `@objectstack/objectql` and cross-realm `instanceof` can't bite) and re-throws + it as `APIError('BAD_REQUEST', …)`. `update-user` and friends now answer with a + `400 { code: 'VALIDATION_FAILED', message, fields }` instead of an opaque 500. + +- bc17d39: fix(auth): provision the better-auth 1.7 columns `sys_team` / `sys_team_member` / `sys_two_factor` were missing (#3624) + + better-auth 1.7.0-rc.1 added fields to three models that the platform objects + never provisioned and `auth-schema-config.ts` never mapped. Because an unmapped + field keeps its camelCase name, the adapter emitted columns no table had: + + | model | field | column now provisioned | + | :----------- | :---------------------------------------- | :---------------------------------------------------------- | + | `team` | `memberCount` | `sys_team.member_count` | + | `teamMember` | `membershipKey` | `sys_team_member.membership_key` | + | `twoFactor` | `failedVerificationCount` / `lockedUntil` | `sys_two_factor.failed_verification_count` / `locked_until` | + + The team pair broke org creation outright. The organization plugin's team + sub-feature is on by default, so `POST /api/v1/auth/organization/create` + auto-creates a default team — and that insert died with `table sys_team has no +column named memberCount` _after_ the organization row had already committed. + Callers got an HTTP 500 on top of a half-created org: a real org row with no + default team behind it. Every multi-org deployment's create-org flow hit this. + + The two-factor pair broke the 2FA lockout path the same way: better-auth + guard-increments `failedVerificationCount` on each wrong code and stamps + `lockedUntil` past the threshold, so a wrong code 500'd instead of being + counted. All four columns are better-auth's own state — provisioned, readable, + and never written from the ObjectStack side. + + Existing environments pick the columns up through the driver's additive schema + sync; no data migration is needed. `member_count` backfills to 0 and + better-auth's own `syncTeamMemberCount` reconciles it on the next membership + change, and `membership_key` stays null on pre-upgrade rows, which better-auth + tolerates by falling back to the `(team_id, user_id)` pair. + + A new drift gate (`better-auth-schema-parity.test.ts`) now asserts that every + column the installed better-auth version can write exists on the platform + object backing it, across the auth manager's whole model surface. The ADR-0092 + D7 guard only ever caught _collisions_ between our extension fields and + better-auth's, so a bump that adds a brand-new field passed the build and failed + at runtime — twice now, counting the 1.7 `oauthAccessToken.authorizationCodeId` + regression. The next one fails the build instead. + +- 65ac468: fix(import): sanitize row errors — never leak raw SQL, map constraint failures to human wording (#3566) + + A failing import row surfaced the driver's raw error verbatim. When a write hit + a DB constraint (e.g. `sys_user.phone_number` is `unique`), the query builder + embeds the entire failing statement in `err.message`, and `toFailedResult` + handed that straight back — so the importer saw `` insert into `sys_user` +(...) values (...) - UNIQUE constraint failed: sys_user.phone_number ``. That is + both unreadable and an information disclosure of the schema. + + - `sanitizeRowError()` (import-runner) maps the common constraint failures — + SQLite / MySQL / Postgres `UNIQUE` and `NOT NULL` — to human wording + ("A record with this `` already exists.", "`` is required.") + and, as a backstop, never lets a message that still reads as a SQL statement + reach the client (it salvages the driver's trailing reason, or falls back to + a generic message). Already-friendly messages (e.g. better-auth's "User + already exists") pass through unchanged. Applies to every import path. + - `isLikelyEmail` now rejects non-ASCII addresses, so an address like + `x@柴仟.com` fails the import **dry-run** pre-check instead of passing client + and dry-run validation only to be rejected by better-auth's strict ASCII + validator at real-import time. + +- 5faeac6: fix(auth): spell isLikelyEmail's ASCII guard with printable bounds (no control char) + + The non-ASCII guard added in framework#3566 was written as `[^\x00-\x7f]`, whose + regex literal embeds a control character (`\x00`). Rewrite it as `[^\x20-\x7e]` — + identical behaviour (anything outside printable ASCII fails the email + pre-filter), but the pattern no longer carries a control character (eslint + `no-control-regex`), and it matches the objectui side's `isPlausibleEmail`. + +- cde1975: fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) + + `os dev` on the stock showcase printed three fixed noise sources on every boot, + with zero example-side changes — training users to ignore warnings. + + - **spec** — add a field-level `ackPlaintextMasking: true` opt-out for the + generic `password` author-time warning (ADR-0100). A deliberately-masked + field (like field-zoo's `f_password`) can now affirm intent instead of + printing an un-actionable "safe to ignore" on every boot; the warning text + points authors at the flag. + - **plugin-auth** — pass better-auth's documented + `silenceWarnings.oauthAuthServerConfig` to `oauthProvider(...)`. We already + mount the `/.well-known/oauth-authorization-server` documents ourselves at + the issuer root, so the plugin's "please ensure it exists" reminder was a + false positive (printed twice); silencing it removes both. + - **objectql** — route the Registry's re-register / package-overwrite lines + (normal rebuild / HMR / seed-replay paths) through a new debug-only + `SchemaRegistry.debug()` so they stay out of the default `info` boot log. Adds + a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so + the debug-gated housekeeping is discoverable for troubleshooting. + +- a629074: fix(auth): the second factor now obeys the operator's lockout policy instead of better-auth's defaults (#3690) + + `auth-manager.ts` constructed `twoFactor()` with a schema and nothing else, so + better-auth's built-in `accountLockout` defaults — on, 10 attempts, 15 minutes — + governed two-factor verification no matter what the admin configured. An operator + who tightened **Setup → Authentication → Account lockout threshold** to 3 got a + password stage that locked at 3 and a second factor that still locked at 10: the + stricter door was the looser one, with nothing in the UI saying so. + + `lockout_threshold` / `lockout_duration_minutes` are now projected onto + better-auth's own `accountLockout` shape (`enabled` / `maxFailedAttempts` / + `durationSeconds`, minutes converted to seconds) rather than growing a parallel + `two_factor_lockout_*` pair — one policy, one mental model, and a future upstream + field arrives as a new option instead of a conflict. The projection goes through + `applyConfigPatch`, which resets the cached better-auth instance, so a settings + change takes effect without a restart. + + Threshold `0` is deliberately **not** forwarded as `enabled: false`. It is the + password stage's "off", and a deployment may leave that stage unlocked because + rate limiting or an IdP covers it; the second factor is the last check before a + session is issued, so it keeps better-auth's default rather than being switched + off by a setting that never mentioned it. + + The threshold field is also no longer hidden behind `email_password_enabled` — + two-factor verification exists in passwordless deployments, where the setting was + previously unreachable. + + The admin **Unlock Account** action now clears both stages. It only ever reset + `sys_user`, so a user locked at the second factor had no admin escape hatch and + had to wait the duration out — survivable while that lock needed 10 failures, + routine once an operator can set the threshold to 3. The second-factor clear is + best-effort and runs after the primary write, so an account with no enrolment + still unlocks normally. + + Note the plugin caps attempts at 5 per challenge (`beginAttempt(5)`), which no + option reaches; a threshold above 5 forces a fresh challenge rather than raising + that cap. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [f92096b] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [ce1f100] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [ef5e72d] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [bbd902d] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/rest@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index fa51ff4c00..c3b07eabc6 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "16.1.0", + "version": "17.0.0-rc.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 5e50fa6aaf..82a7665d71 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,276 @@ # @objectstack/plugin-dev +## 17.0.0-rc.0 + +### Patch Changes + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [735f850] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [f92096b] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [984396b] +- Updated dependencies [d0fea33] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [ce1f100] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [307e0fe] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [aff9e56] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [ef5e72d] +- Updated dependencies [dac6a08] +- Updated dependencies [313d7be] +- Updated dependencies [5faeac6] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [8e08bc3] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [bbd902d] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [bd68f08] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [a629074] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/rest@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/plugin-auth@17.0.0-rc.0 + - @objectstack/plugin-security@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/plugin-hono-server@17.0.0-rc.0 + - @objectstack/service-i18n@17.0.0-rc.0 + - @objectstack/account@17.0.0-rc.0 + - @objectstack/setup@17.0.0-rc.0 + - @objectstack/driver-memory@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 541613a86a..1711975068 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "16.1.0", + "version": "17.0.0-rc.0", "license": "Apache-2.0", "description": "Development Mode Plugin for ObjectStack — auto-enables all services with in-memory implementations", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index fa5e08093c..99cf1c8211 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,138 @@ # @objectstack/plugin-email +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 9b4cd60297..6640c7e80a 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "16.1.0", + "version": "17.0.0-rc.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 895999be7e..ed9f4cf74e 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,541 @@ # @objectstack/plugin-hono-server +## 17.0.0-rc.0 + +### Minor Changes + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- 4ed7ed4: feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710) + + **BREAKING — `allowExport` unset no longer means "inherit read".** Reading a + record and taking a bulk machine-readable copy of the whole table are different + privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite + "Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so. + + ### Migration — FROM → TO + + | | before | after | + | -------------------- | ----------------------------------- | -------------------------- | + | `allowExport` unset | export **allowed** (inherited read) | export **denied** | + | `allowExport: false` | export denied | export denied (unchanged) | + | `allowExport: true` | export allowed | export allowed (unchanged) | + + **The one-line fix:** add `allowExport: true` to the object entry (or the `'*'` + wildcard) of every permission set whose holders should keep exporting. + + ```ts + objects: { + deal: { allowRead: true, allowExport: true }, // ← add the grant + } + ``` + + Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set + that never exported is unaffected. + + **Who is affected.** Package-shipped sets are re-seeded on upgrade, so the + built-ins are handled for you — `admin_full_access` and `organization_admin` now + carry `allowExport: true` explicitly. **Environment-authored sets are not**: any + custom set whose users export must be edited. `member_default` deliberately does + NOT carry the grant, so ordinary authenticated users lose export until an admin + grants it — that is the point of the flip, not an oversight. + + **Merge semantics.** Most-permissive, exactly like the CRUD bits: any set + granting `true` grants export. `false` and unset are the same outcome; `false` + is authoring intent, not a veto, because permission sets are additive capability + containers (ADR-0090). + + **Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no + longer confer export. Separating "may see all data" from "may take a bulk copy" + is the segregation-of-duties case the axis exists for. + + ### Also in this change + + - **spec** — a set carrying `allowExport` is now **high-privilege** + (`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` / + `guest` audience anchors. Without this the opt-in was defeatable by binding an + export-granting set to `everyone`. One predicate, so the runtime anchor gate, + the `@objectstack/lint` security-posture rule and the install-time suggestion + surface all pick it up together. + - **spec / plugin-security** — `ExplainOperationSchema` gains `export`, so + `explain` can answer _why_ a caller got `403 EXPORT_NOT_PERMITTED`. It + explains as `read ∧ the export grant`: `object_crud` reports the conjunction + and attributes the granting set, while every data-shaped layer + (requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed + as the `find` the export actually performs — asking the RLS compiler about an + `export` operation would match no policy and wrongly report "no RLS applies". + `readFilter` is surfaced for `export` as it is for `read`. + - **plugin-reports** — closes the reports side door (#3710). A report rendered + as `csv`/`json` is the same bulk copy of the same object, so it is gated by + the same `ISecurityService.canExport`. Enforced in `executeReport`, which the + interactive run, the ad-hoc run and the scheduled dispatch all funnel through; + `scheduleReport` additionally refuses at create time so an author is not told + at 3am. A schedule created while granted stops delivering once the grant is + revoked. `html_table` stays a read — it is a rendered view, not a bulk copy. + Deployments without `plugin-security` are unaffected (no permission sets + exist, so the axis does not apply). + +- d8c4957: feat: user-level export permission axis (#3544, #3391 follow-up) + + `export` is a user-gated operation, not just "anyone who can list". A permission + set can now deny export on an object while keeping read — matching Salesforce + "Export Reports" / Dynamics "Export to Excel" / NetSuite "Export Lists" / SAP + S_GUI 61. + + - **spec** `ObjectPermissionSchema` gains an optional `allowExport` bit. It is + deliberately OPTIONAL with **no default** so it is a backward-compatible + opt-out: unset → inherits read (today's "can-list ⇒ can-export"), `false` → + export denied while read is kept, `true` → granted. + - **plugin-hono-server** `annotateEffectiveApiOperations` derives + `userExportAllowed = allowExport !== false` from the resolved per-object + permission and threads it into `resolveEffectiveApiMethods` — so `export` + derives from `list ∧ userExportAllowed`. When the axis removes `export` from + an otherwise-open object, the object is now annotated (the effective set minus + `export`) so the client hides the Export button; an unrestricted object with + export still allowed stays unannotated (client default-allow). + + Wires the `userExportAllowed` slot reserved in #3391 P1 — zero contract change + to the derivation table or the frontend (it already consumes the effective + `apiOperations`). Backward-compatible: existing permission sets (no + `allowExport`) keep today's behavior everywhere. + +### Patch Changes + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- c2d9098: feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + + Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already + surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` + #3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did + not — the same strips happened silently on every batched row — and the typed + client warning + CORS mirror were deferred. This closes those out. + + **Bulk passthrough (metadata-protocol).** + + - `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. + - `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. + - `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. + - **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + + **Contract (spec).** `BatchOperationResultSchema` gains an optional per-row + `droppedFields` (covers `updateMany` + `batch`, which alias + `BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional + aggregated `droppedFields`. Both are omit-when-empty, so existing clients are + unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for + batches — one response header cannot express per-row drops, so the per-row body + field is the canonical bulk channel. + + **Typed client warnings (@objectstack/client).** `CreateDataResult` / + `UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body + channel a type instead of an untyped property. + + **CORS (@objectstack/hono, @objectstack/plugin-hono-server).** + `x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` + allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin + browser can read the single-write drop header. The body `droppedFields` remains + the primary, cross-origin-safe surface — this is a convenience mirror. + + **GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, + but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and + `handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There + is no schema generator or mutation resolver to expose a typed payload field on, + so there is nothing to wire until a GraphQL engine lands — at which point the + protocol-layer `droppedFields` is already present and only the GraphQL schema + projection would remain. + +- 9613396: feat(security): ENFORCE the user-level export axis on the server (#3544) + + `allowExport` landed as a spec bit plus a `/me/permissions` annotation, which + hid the client's Export button — and nothing else. Because `export ⊆ list`, the + REST export route streams through `findData` and the engine middleware sees an + ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller + holding `allowExport: false` could still `curl +/api/v1/data/:object/export` and drain the whole table. Declared, not enforced. + + - **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is + now a real decision: `export` = read granted ∧ not explicitly denied. + `allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map + means "the bit must be truthy", which would have denied export to every + permission set authored before the axis existed. The new exported + `resolveUserExportAllowed()` folds the tri-state across sets (`true` beats + `false` beats unset) exactly as the `/me/permissions` merge does. + - **spec** `ISecurityService` gains `canExport(object, context)` — the question a + bulk-egress door outside the engine middleware has to ask before it reads. + Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the + middleware. + - **rest** `GET /data/:object/export` calls it and answers **403 + `EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the + object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405 + says the object exposes no export, 403 says this caller may not use it. No + security service (no `plugin-security` ⇒ no permission sets) → allowed, the + same fail-open posture as every other permission gate in that layer; service + present but unable to answer → denied. + - **plugin-hono-server** the `/me/permissions` annotation now falls back to the + `'*'` entry's export bit when a per-object entry declares none, matching the + evaluator's own wildcard fallback — so a set that denies export wholesale via + `'*'` no longer offers a button the server refuses. + + Backward-compatible: `allowExport` is still an opt-out with no default, so an + unset bit inherits read and existing permission sets behave exactly as before. + Only a permission set that explicitly sets `allowExport: false` changes — and it + now changes on the server, which is the point. + + Implementers of `ISecurityService` outside this repo must add `canExport`; the + interface member is required, matching how `getReadableFields` was added. + Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a + partial implementation degrades rather than throwing. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index da293c9e31..f2231b1f93 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": "16.1.0", + "version": "17.0.0-rc.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 4b09367e12..0b07e7d88d 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,93 @@ # @objectstack/plugin-pinyin-search +## 17.0.0-rc.0 + +### Patch Changes + +- 9f060e5: chore(deps)!: better-auth 1.7.0-rc.2 (account identity restructuring) + the + production-dependency batch from #3517 + + **better-auth 1.7.0-rc.1 → 1.7.0-rc.2** across the family (`better-auth`, + `@better-auth/core`, `@better-auth/oauth-provider`, `@better-auth/sso`, and the + adapter/telemetry overrides). `@better-auth/scim` deliberately stays on + 1.7.0-rc.1 — rc.2 replaces its whole model (code-defined connections; the + `scimProvider` model and the generate-token endpoint are gone), which is a + feature migration, not a version bump. Its peer range accepts rc.2 core, and the + advisory that forced the original pin (GHSA-j8v8-g9cx-5qf4) is still fixed. + + **BREAKING — account identity.** better-auth renamed `account.accountId` to + `account.providerAccountId` and added a REQUIRED `account.issuer`; sign-in now + resolves accounts by `(issuer, providerAccountId)`. + + - FROM `fields: { accountId: 'account_id' }` → TO + `fields: { issuer: 'issuer', providerAccountId: 'account_id' }`. The provider + account id keeps its `account_id` column — only the better-auth-side name + moved — and `sys_account` gains an `issuer` column. + - FROM `internalAdapter.createAccount({ providerId, accountId, … })` → TO + `createAccount({ providerId, issuer, providerAccountId, … })`. A local + password account carries the issuer better-auth mints for itself, + `local:credential`. + - FROM `client.auth.accounts.unlink({ providerId, accountId })` → TO + `unlink({ accountId })`, where `accountId` is now the account ROW id (the `id` + from `accounts.list()`), matching better-auth's narrowed body. + `accounts.list()` returns `issuer` + `providerAccountId` in place of + `accountId`. + + **Existing deployments:** rows written before 1.7 have no issuer and are + invisible to sign-in until stamped. The auth plugin now runs an idempotent + boot-time backfill that stamps what it can derive — `local:credential` for + password accounts, `local:oauth:` for configured social providers, + and the registered IdP's real `iss` from `sys_sso_provider` for federated ones. + Accounts from a federated IdP that is no longer registered cannot be derived; + they are logged with their provider id and row count rather than guessed, and + those users cannot sign in through that provider until the row is stamped with + the IdP's issuer or removed so a fresh login re-links it. + + **Also required by 1.7:** `SecondaryStorage` gained two mandatory methods, both + now implemented over the kernel cache service — `getAndDelete` (single-use + verification values) and `increment` (fixed-window rate-limit counter; + `rateLimit.storage: 'secondary-storage'` throws at boot without it). + + The rest of #3517's production-dependency batch rides along: `@oclif/core` + 4.13.0, `@hono/node-server` 2.0.12, `hono` 4.12.32, `tar` 7.5.22, `jose` 6.2.4, + `pinyin-pro` 3.28.2, plus the private docs app's fumadocs/next/react bumps. + +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [a749273] +- Updated dependencies [fdb4f50] +- Updated dependencies [879ea13] +- Updated dependencies [840ee4b] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [b949059] +- Updated dependencies [c5ff96d] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [32d3800] +- Updated dependencies [a227ed7] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [4cca74c] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [030125b] +- Updated dependencies [8e08bc3] +- Updated dependencies [0c302a7] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index ef0f57ebf7..b6cb906bd9 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": "16.1.0", + "version": "17.0.0-rc.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 0bb7f85ad6..6c4f5f5a4c 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,208 @@ # @objectstack/plugin-reports +## 17.0.0-rc.0 + +### Major Changes + +- 4ed7ed4: feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710) + + **BREAKING — `allowExport` unset no longer means "inherit read".** Reading a + record and taking a bulk machine-readable copy of the whole table are different + privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite + "Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so. + + ### Migration — FROM → TO + + | | before | after | + | -------------------- | ----------------------------------- | -------------------------- | + | `allowExport` unset | export **allowed** (inherited read) | export **denied** | + | `allowExport: false` | export denied | export denied (unchanged) | + | `allowExport: true` | export allowed | export allowed (unchanged) | + + **The one-line fix:** add `allowExport: true` to the object entry (or the `'*'` + wildcard) of every permission set whose holders should keep exporting. + + ```ts + objects: { + deal: { allowRead: true, allowExport: true }, // ← add the grant + } + ``` + + Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set + that never exported is unaffected. + + **Who is affected.** Package-shipped sets are re-seeded on upgrade, so the + built-ins are handled for you — `admin_full_access` and `organization_admin` now + carry `allowExport: true` explicitly. **Environment-authored sets are not**: any + custom set whose users export must be edited. `member_default` deliberately does + NOT carry the grant, so ordinary authenticated users lose export until an admin + grants it — that is the point of the flip, not an oversight. + + **Merge semantics.** Most-permissive, exactly like the CRUD bits: any set + granting `true` grants export. `false` and unset are the same outcome; `false` + is authoring intent, not a veto, because permission sets are additive capability + containers (ADR-0090). + + **Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no + longer confer export. Separating "may see all data" from "may take a bulk copy" + is the segregation-of-duties case the axis exists for. + + ### Also in this change + + - **spec** — a set carrying `allowExport` is now **high-privilege** + (`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` / + `guest` audience anchors. Without this the opt-in was defeatable by binding an + export-granting set to `everyone`. One predicate, so the runtime anchor gate, + the `@objectstack/lint` security-posture rule and the install-time suggestion + surface all pick it up together. + - **spec / plugin-security** — `ExplainOperationSchema` gains `export`, so + `explain` can answer _why_ a caller got `403 EXPORT_NOT_PERMITTED`. It + explains as `read ∧ the export grant`: `object_crud` reports the conjunction + and attributes the granting set, while every data-shaped layer + (requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed + as the `find` the export actually performs — asking the RLS compiler about an + `export` operation would match no policy and wrongly report "no RLS applies". + `readFilter` is surfaced for `export` as it is for `read`. + - **plugin-reports** — closes the reports side door (#3710). A report rendered + as `csv`/`json` is the same bulk copy of the same object, so it is gated by + the same `ISecurityService.canExport`. Enforced in `executeReport`, which the + interactive run, the ad-hoc run and the scheduled dispatch all funnel through; + `scheduleReport` additionally refuses at create time so an author is not told + at 3am. A schedule created while granted stops delivering once the grant is + revoked. `html_table` stays a read — it is a rendered view, not a bulk copy. + Deployments without `plugin-security` are unaffected (no permission sets + exist, so the axis does not apply). + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 0fe029548c..1495f3af1d 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "16.1.0", + "version": "17.0.0-rc.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 fe69fc785d..6b1023595e 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,819 @@ # @objectstack/plugin-security +## 17.0.0-rc.0 + +### Major Changes + +- 4ed7ed4: feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710) + + **BREAKING — `allowExport` unset no longer means "inherit read".** Reading a + record and taking a bulk machine-readable copy of the whole table are different + privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite + "Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so. + + ### Migration — FROM → TO + + | | before | after | + | -------------------- | ----------------------------------- | -------------------------- | + | `allowExport` unset | export **allowed** (inherited read) | export **denied** | + | `allowExport: false` | export denied | export denied (unchanged) | + | `allowExport: true` | export allowed | export allowed (unchanged) | + + **The one-line fix:** add `allowExport: true` to the object entry (or the `'*'` + wildcard) of every permission set whose holders should keep exporting. + + ```ts + objects: { + deal: { allowRead: true, allowExport: true }, // ← add the grant + } + ``` + + Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set + that never exported is unaffected. + + **Who is affected.** Package-shipped sets are re-seeded on upgrade, so the + built-ins are handled for you — `admin_full_access` and `organization_admin` now + carry `allowExport: true` explicitly. **Environment-authored sets are not**: any + custom set whose users export must be edited. `member_default` deliberately does + NOT carry the grant, so ordinary authenticated users lose export until an admin + grants it — that is the point of the flip, not an oversight. + + **Merge semantics.** Most-permissive, exactly like the CRUD bits: any set + granting `true` grants export. `false` and unset are the same outcome; `false` + is authoring intent, not a veto, because permission sets are additive capability + containers (ADR-0090). + + **Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no + longer confer export. Separating "may see all data" from "may take a bulk copy" + is the segregation-of-duties case the axis exists for. + + ### Also in this change + + - **spec** — a set carrying `allowExport` is now **high-privilege** + (`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` / + `guest` audience anchors. Without this the opt-in was defeatable by binding an + export-granting set to `everyone`. One predicate, so the runtime anchor gate, + the `@objectstack/lint` security-posture rule and the install-time suggestion + surface all pick it up together. + - **spec / plugin-security** — `ExplainOperationSchema` gains `export`, so + `explain` can answer _why_ a caller got `403 EXPORT_NOT_PERMITTED`. It + explains as `read ∧ the export grant`: `object_crud` reports the conjunction + and attributes the granting set, while every data-shaped layer + (requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed + as the `find` the export actually performs — asking the RLS compiler about an + `export` operation would match no policy and wrongly report "no RLS applies". + `readFilter` is surfaced for `export` as it is for `read`. + - **plugin-reports** — closes the reports side door (#3710). A report rendered + as `csv`/`json` is the same bulk copy of the same object, so it is gated by + the same `ISecurityService.canExport`. Enforced in `executeReport`, which the + interactive run, the ad-hoc run and the scheduled dispatch all funnel through; + `scheduleReport` additionally refuses at create time so an author is not told + at 3am. A schedule created while granted stops delivering once the grant is + revoked. `html_table` stays a read — it is a rendered view, not a bulk copy. + Deployments without `plugin-security` are unaffected (no permission sets + exist, so the axis does not apply). + +### Minor Changes + +- 7fb436c: Multi-organization operation is an ENTITLEMENT again: the `group` posture no + longer activates without the enterprise runtime (ADR-0105 D12 correction). + + The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the + posture self-activating — it never probed for `@objectstack/organizations`. That + turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 + D2), and made the weaker isolation the free one, which is not a boundary anyone + would draw on purpose. + + The distinction that was missed: **open code is not free activation.** The wall's + implementation has always lived in the open packages — that is equally true of + `isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a + service the enterprise package registers. Cloud ADR-0016's 铁律 + (强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is + safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture + away. + + ## Changes + + - **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`. + Without it the posture resolves to `single` and reports `degraded`. + - **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE + instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped + both the enterprise package load AND the fail-fast, silently degrading to an + unwalled deployment — the exact ADR-0049 class that guard exists to close. A + `group` request without the runtime now refuses to boot unless + `OS_ALLOW_DEGRADED_TENANCY=1`. + - **New seam — the runtime declares what it entitles.** `org-scoping` may expose + `supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`); + the open side honours it and fails closed on anything not listed. Whether + `group` and `isolated` are one commercial tier or two is packaging policy, and + packaging policy belongs to the commercial runtime rather than hard-coded in + open core. Omitting the field entitles every walled posture, so existing + runtimes are unaffected. + - **`organization_id` stamping returns to the enterprise runtime.** The previous + wave moved auto-stamping into the open engine; that removed the closed + package's only load-bearing runtime duty, so a five-line forged `org-scoping` + registration would have produced a fully working multi-org deployment. With + stamping back where it was, a forged registration yields NULL-org rows the wall + hides — a broken deployment, not an unlicensed working one. + + **Write-side VALIDATION stays open and is unchanged**, including the + bulk-insert coverage: rejecting a forged `organization_id` is a security + property, not a packaging one. Only filling an ABSENT value moved back. + + - Default-organization bootstrap returns to `single`-only; every walled posture + keeps its existing owner (ADR-0081 D1). + + ## Note for operators + + `OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now + **refuses to boot** rather than running single-org. This only affects + deployments that adopted `group` between the two waves. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 7c7e246: feat(authz): expose the caller's delegable scope — the read half of the + delegated-administration gate (ADR-0090 D12 / ADR-0105 D8) + + `adminScope` decided writes but could not be READ: `assignablePermissionSets` + lived only inside `delegated-admin-gate.ts`, so a UI offering "place this + person in a unit, with these positions" (the D8 scoped-invitation form) had no + way to narrow its pickers. It would list the whole tree and let the user + discover the boundary by being refused — which turns an authorization gate into + a validator and makes the boundary invisible until it bites. + + `ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as + `GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`: + + - `placeableBusinessUnitIds` — union of the subtrees where the caller may place + people (scopes granting `manageAssignments`); + - `assignablePositions` — positions whose every distributed permission set the + caller may hand out (containment check included); + - `scopes` — the held `adminScope`s with subtrees resolved, for attribution; + - `isTenantAdmin` — unconstrained, with everything enumerated so a consumer + renders ONE uniform picker instead of special-casing. + + Computed by the same helpers the write gate enforces with, so an option this + reports is one `assert()` accepts — a test asserts that agreement directly. It + NARROWS; the gate still decides. + + Strictly self-scoped: no target-user parameter, so it discloses nothing beyond + the authority the caller already holds (unlike `explain`, which has one and + gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with + no delegated authority gets empty lists, and a deployment without + `@objectstack/plugin-security` gets 501. + +- 9613396: feat(security): ENFORCE the user-level export axis on the server (#3544) + + `allowExport` landed as a spec bit plus a `/me/permissions` annotation, which + hid the client's Export button — and nothing else. Because `export ⊆ list`, the + REST export route streams through `findData` and the engine middleware sees an + ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller + holding `allowExport: false` could still `curl +/api/v1/data/:object/export` and drain the whole table. Declared, not enforced. + + - **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is + now a real decision: `export` = read granted ∧ not explicitly denied. + `allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map + means "the bit must be truthy", which would have denied export to every + permission set authored before the axis existed. The new exported + `resolveUserExportAllowed()` folds the tri-state across sets (`true` beats + `false` beats unset) exactly as the `/me/permissions` merge does. + - **spec** `ISecurityService` gains `canExport(object, context)` — the question a + bulk-egress door outside the engine middleware has to ask before it reads. + Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the + middleware. + - **rest** `GET /data/:object/export` calls it and answers **403 + `EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the + object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405 + says the object exposes no export, 403 says this caller may not use it. No + security service (no `plugin-security` ⇒ no permission sets) → allowed, the + same fail-open posture as every other permission gate in that layer; service + present but unable to answer → denied. + - **plugin-hono-server** the `/me/permissions` annotation now falls back to the + `'*'` entry's export bit when a per-object entry declares none, matching the + evaluator's own wildcard fallback — so a set that denies export wholesale via + `'*'` no longer offers a button the server refuses. + + Backward-compatible: `allowExport` is still an opt-out with no default, so an + unset bit inherits read and existing permission sets behave exactly as before. + Only a permission set that explicitly sets `allowExport: false` changes — and it + now changes on the server, which is the point. + + Implementers of `ISecurityService` outside this repo must add `canExport`; the + interface member is required, matching how `getReadableFields` was added. + Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a + partial implementation degrades rather than throwing. + +- aa8b847: feat(authz): scoped invitations — placement intent on an invitation, gated by + the issuer's adminScope and applied on acceptance (ADR-0105 D8) + + An invitation may now carry PLACEMENT INTENT — the business unit the invitee + lands in and the positions they are assigned — so a delegated (plant) admin's + invitee arrives already in the right unit and role instead of waiting on a + platform admin. This closes the structural gap ADR-0105 D8 names for + `single`-posture deployments and is the natural admission path under `group`. + + The two halves ship together, deliberately: + + - **Issuance is authorized** against the ISSUER's `adminScope` (ADR-0090 D12), + by dry-running the existing `DelegatedAdminGate` against the very + `sys_user_position` rows the acceptance would write. The gate is reused + verbatim — no second copy of the subtree/allowlist logic to drift — so an + invitation can never place what its issuer could not have assigned directly. + Without that gate the feature would be an escalation hole: the built-in + `organization_admin` is deliberately read-only on the RBAC tables precisely + so a fresh org admin cannot rebind themselves, and applying an unchecked + invitation payload under system context would hand that authority straight + back. + - **Acceptance applies it**, idempotently and failure-isolated: a replayed + acceptance converges instead of duplicating assignments, and a placement + miss never undoes a valid membership. + + Surface: + + - `sys_invitation` gains `business_unit_id` + `positions` (ADR-0092 extension + fields, registered in the D7 collision-guarded whitelist; NOT generically + editable — placement is set only at issuance, through the gate). + - `@objectstack/plugin-security` registers the `invitation-placement` service + (`assertIssuable` / `apply`). + - `@objectstack/plugin-auth` wires better-auth's `beforeCreateInvitation` / + `afterAcceptInvitation` to it. **Fail closed**: an invitation that requests + placement in a deployment without the delegated-administration runtime is + refused, never silently placed unchecked. + + Existing invitations are unaffected — an invitation without placement intent + never consults the gate and behaves exactly as before. + +- d318b24: feat: `security.getReadableFields` query surface for export column projection (#3547, #3391 follow-up) + + The REST export route projected its columns by inferring readability from the + first chunk of already-masked data rows (#3498). That has two known + compromises: a readable column whose first-chunk values are all null (and thus + omitted by the driver) drops out of the header, and an empty result set leaves + nothing to narrow. This adds the long-term-correct path. + + - **plugin-security** — the `security` service gains + `getReadableFields(object, context)`. It resolves the caller's permission + sets and builds the field-permission map with the SAME evaluator + + `requiredPermissions` fold the read middleware's `FieldMasker` uses (and the + same on-behalf-of delegator intersection, fail-closed on a dangling + delegator), then returns every schema field NOT masked non-readable — the + exact complement of what the mask deletes, so it can never drift from + data-plane FLS. Computed from schema + context, never from data rows: immune + to null values and empty result sets. A system context bypasses FLS; an + unresolvable schema returns `undefined` so callers fall back. + - **rest** — the `GET /data/:object/export` route asks the environment's + `security` service for `getReadableFields(object, context)` and projects the + schema-derived header to that set BEFORE streaming. When no security service + is reachable (no plugin-security / single-kernel without a provider) it + degrades to the existing masked-row inference, so there is zero regression. + Explicit `?fields=` requests are still honored verbatim. + + Contract-neutral: export columns already equal list's readable columns + (`export ⊆ list`, #3391); this makes the projection authoritative instead of + inferred. + +### Patch Changes + +- 735f850: fix(security): resolve the ISSUER's real grants when authorizing invitation + placement (ADR-0105 D8) + + Scoped-invitation issuance dry-runs `DelegatedAdminGate` against the + `sys_user_position` rows the acceptance would write. The gate reads authority + off `context.positions` / `context.permissions` — but the invitation hook + handed it a hand-built `{ userId, tenantId }`, which carries neither. Every + delegated administrator therefore resolved to the additive baseline alone and + was refused: + + > requires tenant-level administration or a delegated adminScope (ADR-0090 D12) + + Fail-closed, but dead: only a tenant admin could ever issue a placement, which + is the one case the feature was not for. Caught by cloud's group-posture + dogfood, which exercises the real HTTP path with a real delegate. + + `assertIssuable` now takes `actorUserId` instead of a caller-built + `actorContext` and resolves that user's grants itself through the single authz + resolver (`@objectstack/core` `resolveUserAuthzGrants`) — the same envelope a + transport would have carried, from the same reads. There is no request to + resolve a context from inside a better-auth hook, so the id is what the caller + can honestly supply and the resolution belongs behind the boundary. + + A principal-less call still reaches the gate with an empty context on purpose: + the gate owns that refusal too, so the security boundary keeps exactly one + place an issuance can be denied. + +- 307e0fe: fix(security): govern `sys_member` writes — organization membership is not a delegable capability (#3697 follow-up) + + `DelegatedAdminGate`'s `GOVERNED_OBJECTS` covered the four RBAC link tables but + not `sys_member`, so the table that decides _who is an org admin_ was the one + authority surface the delegated-administration gate never saw. + + That matters because a membership row is an authority dial: `role` containing + `owner`/`admin` is auto-elevated to `organization_admin` by + `auto-org-admin-grant.ts`, and that set's wildcard `modifyAllRecords` is exactly + what `isTenantAdmin()` tests. Writing one mints a tenant admin — the same + escalation the invitation role cap closes on the issuance path, one layer down + at the table. + + **Not exploitable today, and this changes no working behaviour.** Every + `sys_member` writer is a better-auth path running under `isSystem`, which + short-circuits the whole security middleware before this gate; the ADR-0092 D2 + identity write guard refuses user-context writes to better-auth-managed tables + upstream of it. The gate is added so the chain cannot silently reopen the day a + direct-write surface is introduced — a `case` label is not enforcement, and the + call site is what decides (AGENTS.md Prime Directive #10). + + The rule is tenant-admin-only rather than scope-delegable, deliberately: no axis + of `AdminScope` expresses "organization membership" (its vocabulary is BU + subtree, action flags and an assignable-set allowlist), so there is nothing for + a delegated scope to approve part of — and a delegate who could write one would + mint authority strictly greater than their own, which is what ADR-0090 D12 + exists to prevent. Adding people to an organization already has a delegable + path: the **invitation**, whose placement is authorized against the issuer's + `adminScope` and whose role is capped at the issuer's own grade. The refusal + message says so. + +- 0e3a226: fix(authz): widen the driver's native tenant scope to the membership union + under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623) + + The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids` + under `group`, but the ObjectQL engine also propagated the active-org + `tenantId` into `DriverOptions` unconditionally, and the SQL driver's native + scoping ANDed `organization_id = tenantId` under the union — collapsing every + group read back to active-org (isolated) reach. Found by the cloud-side + `ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group` + against a real driver. + + - `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with + native scoping widen reads/updates/deletes/aggregates to `IN (...)`, + keeping the NULL-tenant global-row carve-out; inserts still stamp from + `tenantId` (the active organization is the write target, D5). Absent or + empty ⇒ equality fallback — fail toward isolation, never toward exposure. + - ObjectQL engine threads `ExecutionContext.accessible_org_ids` as + `tenantIds` when the tenancy posture is `group`, reported by a new + `setTenancyPostureProvider` seam. + - SecurityPlugin wires that provider at start — deliberately from the + enforcement layer, so the driver wall only widens while the Layer 0 union + wall enforces above it. Embeddings without plugin-security keep active-org + equality. + +- d1cabaa: fix(i18n): translate the SSO / SCIM / user-position / import-job admin objects + + Four live, UI-facing system objects were registered but never added to their + package's i18n extract config, so non-English admins saw raw English `label` + metadata: + + - `sys_sso_provider`, `sys_scim_provider` (platform-objects) — identity-provider + admin grids plus the register / verify-domain actions. + - `sys_user_position` (plugin-security) — delegated position assignment + (`userActions` create/edit/delete); its sibling `sys_user_permission_set` was + already translated, so this closes an inconsistency. + - `sys_import_job` (platform-objects) — import history / progress, alongside the + already-translated `sys_job` / `sys_job_run`. + + Adds each object to its package's `scripts/i18n-extract.config.ts` and supplies + real zh-CN / ja-JP / es-ES translations across all four locale bundles, and + extends the bundle-ownership guards' `OWNED_OBJECTS` to cover them. The + orphan-only guards from #3502 could not catch this "owned-and-live-but-never- + extracted" gap. + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- 7180ed5: fix(security): fail closed when an object's security posture can't be resolved + (#3545) + + #3545 accepted the API-exposure gate's fail-open on unresolvable metadata on one + load-bearing premise: that gate is a SURFACE-AREA control, while the real + authorization boundary — auth + the ObjectQL security middleware (CRUD/FLS/RLS) + — enforces unconditionally on the data call whatever the gate answers. + + Verifying that premise rather than assuming it shows it did not hold. The + middleware does run unconditionally, but two of its INPUTS were read from the + same object metadata and defaulted permissively when it could not be resolved, + so the very trigger the issue is about reached one layer PAST the gate, into the + boundary itself: an unresolved `access.default` read as PUBLIC (so a plain `'*'` + wildcard covered an object ADR-0066 D2 excludes from it) and an unresolved + `requiredPermissions` read as NO CONTRACT (so the D3 capability AND-gate was + skipped entirely). + + `getObjectSecurityMeta` now flags `unresolved`, and the three consumers that turn + posture into an access decision fail closed on it: the middleware denies (with an + error log, so a persistent metadata outage is observable rather than a silent + blanket-allow), `canExport` denies, and `getReadableFields` exposes no columns — + the same stance already taken for a permission-resolution failure and a dangling + delegator. `computeLayeredRlsFilter` keeps consuming the defaults deliberately: + there the permissive value WITHHOLDS the cross-tenant exemption, so it is already + the closed direction. + + Blast radius is bounded to the risky case. System/boot writes (`isSystem`) and + principal-less/anonymous contexts short-circuit earlier in the middleware, so + reaching the new check means an authenticated principal with resolved grants + asking for an object whose declaration is missing; the cold-start window is + served by those short-circuits, not by the permissive default. The exposure + gate's own tiered decision (transient unavailability → fail open) is therefore + unchanged — it now rests on a boundary that actually holds. + + The explain engine reports the denial on its existing `object_crud` layer naming + the real cause, so the "why am I denied?" surface cannot drift from enforcement. + +- db48ad5: fix(security,approvals,metadata-core): restore batch routes on the eight objects the #3391 P1 companion fix missed (#3026) + + The #3391 P1 contract made the bulk gate `bulk ∧ derived(child)`: a batch + request is admitted only when the object grants the `bulk` **primitive** and the + batched child operation is itself allowed. Before that, the `*Many` routes + checked only the child verb, so a boilerplate CRUD-five whitelist + (`['get','list','create','update','delete']`) batched fine. + + The companion fix — adding the `bulk` primitive wherever an explicit whitelist + survived — was applied only inside `platform-objects`. Eight objects carrying + the same boilerplate live in other packages and kept the gap, so `/batch`, + `createMany`, `updateMany` and `deleteMany` answered `405 +OBJECT_API_METHOD_NOT_ALLOWED` on objects whose single-record create/update/ + delete were wide open. `data-objectstack` rethrows that 405 without falling back + to per-row writes, which surfaced as a hard error on multi-select delete in the + Setup grids. + + Objects reclaimed (whitelist now `['get','list','create','update','delete','bulk']`): + `sys_capability`, `sys_permission_set`, `sys_position`, + `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position` + (plugin-security); `sys_approval_delegation` (plugin-approvals); + `sys_view_definition` (metadata-core). + + No new authority is granted: `bulk` only permits batching verbs each object + already exposes one record at a time, and every batched row still passes the + same row- and field-level permission checks. The whitelists stay explicit rather + than being deleted — seven of the eight are `managedBy`, and + `reconcileManagedApiMethods` (ADR-0103 D3) early-returns on a non-array + `apiMethods`, so dropping the line would silently disable the managed-write + backstop. + +- 1659072: feat(spec): publish `ISecurityService` — the `security` service surface becomes an enforced contract + + The `security` service registers seven cross-package methods (`getReadFilter`, + `getReadableFields`, `resolvePermissionSetNames`, `explain`, and the three + audience-binding suggestion calls) but had no contract in + `@objectstack/spec/contracts`. Consumers duck-typed it, and each one invented its + own fallback for a missing method or an "empty" answer — with more consumers + arriving, that is a drift surface. + + `ISecurityService` now documents the surface, and both ends are typed against it + so it is **enforced rather than declared**: `plugin-security` assigns its + registration to `ISecurityService` (a renamed, dropped, or re-typed method fails + that build), and the REST layer resolves the service as a `Partial` + (so call sites must keep feature-detecting instead of assuming the full surface). + + The contract makes explicit the one thing consumers cannot guess — that the + methods do **not** share a failure convention: + + - `getReadFilter` fails **CLOSED**: a resolution failure yields a deny filter + matching zero rows, never `undefined`. `undefined` means "no row restriction", + and nothing else. + - `getReadableFields` fails **SOFT**: `undefined` means "no answer, use your own + projection", while `[]` is authoritative and means "no field is readable" — + opposite instructions that a consumer must not conflate. + + Typing the producer immediately caught one real discrepancy, fixed here: + `getReadFilter` declared `Promise | null | undefined>` + while every return path yields a filter or `undefined` (`filter ?? undefined` + normalizes the null away). The dead `| null` is removed, so "no restriction" has + exactly one representation. Type-level only — no runtime behaviour changes. + +- f00d8d4: fix(sharing): remove the `full` access level — it promised delete/transfer/share and granted `edit` (#3865) + + `sys_sharing_rule.access_level` / `sys_record_share.access_level` offered three + levels, the third documented as **Full Access (Transfer, Share, Delete)**. No + code path granted transfer, re-share, or delete because of it: both enforcement + sites matched `access_level in ('edit','full')`, so `full` was byte-equivalent + to `edit`. An admin picking "Full Access" in Setup was told they had granted + delete rights and had not — declared-but-unenforced metadata (ADR-0078, + ADR-0049), the same defect that retired the `queue` recipient before it. + + Measured on showcase, a `full` recipient got `read: allowed`, `update: allowed`, + `delete: DENIED` — and the denial came from `decidedBy=object_crud`, i.e. the + object-level CRUD gate rejected the delete _before_ sharing was consulted at + all. That is not an oversight to patch around; it is the model working. Record + sharing widens **which rows** a principal reaches, never **which verbs** they + may use — the same split Salesforce enforces (its sharing rules stop at + Read-Only / Read-Write; Full Access is owner / hierarchy / Modify All only, + never grantable by a rule) and Dataverse enforces by AND-ing every shared access + right against the security role's own privilege. Delete and transfer belong to + ownership, the ADR-0057 DEPTH scopes, and admin scope. + + **What changed** + + - `SharingLevel` (spec/security) and `ShareAccessLevel` (spec/contracts) are now + `read | edit`. The `Field.select` on both objects offers the same two, so the + Setup dropdown no longer shows the misleading option. + - `SharingService.grant()` and `SharingRuleService.defineRule()` gained the + access-level validation they never had: `full` normalises to `edit`, and an + unrecognised level is a `VALIDATION_FAILED` (HTTP 400) instead of being + persisted verbatim as a grant no gate would ever match. + - Enforcement stays deliberately wider than authoring — the read/write gates + still match `edit`/`full` — so a row written before this release keeps + working. Narrowing them would silently _revoke_ access. + - A boot backfill normalises stored `full` rows on both tables, and the + `sharing-rule-access-level-full-to-edit` conversion rewrites declarative + stacks at load, so nothing needs consumer action. + + **Migration.** None. `full` and `edit` were already behaviourally identical, so + rewriting one to the other cannot change an access decision — unlike the OWD + `sharingModel: 'full'` alias retired in ADR-0090 D4, which changed posture and + had to be delegated to the author. A stack that still authors `accessLevel: +'full'` converts at load with a deprecation notice; stored rows normalise at + next boot. Code that pinned the `ShareAccessLevel` type to `'full'` no longer + compiles — use `'edit'`. + + Reviving a real per-record delete grant is a separate design (a capability mask + AND-ed with object CRUD, plus the share-administration model that would have to + authorise re-sharing), not a fourth enum member. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 939595e858..20b5089b1b 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "16.1.0", + "version": "17.0.0-rc.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 a314cc7021..febcce874a 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,346 @@ # @objectstack/plugin-sharing +## 17.0.0-rc.0 + +### Minor Changes + +- f00d8d4: fix(sharing): remove the `full` access level — it promised delete/transfer/share and granted `edit` (#3865) + + `sys_sharing_rule.access_level` / `sys_record_share.access_level` offered three + levels, the third documented as **Full Access (Transfer, Share, Delete)**. No + code path granted transfer, re-share, or delete because of it: both enforcement + sites matched `access_level in ('edit','full')`, so `full` was byte-equivalent + to `edit`. An admin picking "Full Access" in Setup was told they had granted + delete rights and had not — declared-but-unenforced metadata (ADR-0078, + ADR-0049), the same defect that retired the `queue` recipient before it. + + Measured on showcase, a `full` recipient got `read: allowed`, `update: allowed`, + `delete: DENIED` — and the denial came from `decidedBy=object_crud`, i.e. the + object-level CRUD gate rejected the delete _before_ sharing was consulted at + all. That is not an oversight to patch around; it is the model working. Record + sharing widens **which rows** a principal reaches, never **which verbs** they + may use — the same split Salesforce enforces (its sharing rules stop at + Read-Only / Read-Write; Full Access is owner / hierarchy / Modify All only, + never grantable by a rule) and Dataverse enforces by AND-ing every shared access + right against the security role's own privilege. Delete and transfer belong to + ownership, the ADR-0057 DEPTH scopes, and admin scope. + + **What changed** + + - `SharingLevel` (spec/security) and `ShareAccessLevel` (spec/contracts) are now + `read | edit`. The `Field.select` on both objects offers the same two, so the + Setup dropdown no longer shows the misleading option. + - `SharingService.grant()` and `SharingRuleService.defineRule()` gained the + access-level validation they never had: `full` normalises to `edit`, and an + unrecognised level is a `VALIDATION_FAILED` (HTTP 400) instead of being + persisted verbatim as a grant no gate would ever match. + - Enforcement stays deliberately wider than authoring — the read/write gates + still match `edit`/`full` — so a row written before this release keeps + working. Narrowing them would silently _revoke_ access. + - A boot backfill normalises stored `full` rows on both tables, and the + `sharing-rule-access-level-full-to-edit` conversion rewrites declarative + stacks at load, so nothing needs consumer action. + + **Migration.** None. `full` and `edit` were already behaviourally identical, so + rewriting one to the other cannot change an access decision — unlike the OWD + `sharingModel: 'full'` alias retired in ADR-0090 D4, which changed posture and + had to be delegated to the author. A stack that still authors `accessLevel: +'full'` converts at load with a deprecation notice; stored rows normalise at + next boot. Code that pinned the `ShareAccessLevel` type to `'full'` no longer + compiles — use `'edit'`. + + Reviving a real per-record delete grant is a separate design (a capability mask + AND-ed with object CRUD, plus the share-administration model that would have to + authorise re-sharing), not a fourth enum member. + +- 503be86: feat(security)!: reconcile the SharingRule authoring surface with the enforced runtime — rename `group` → `team`, add `business_unit`, prune `guest` + owner-type rules (#1878) + + The authoring `ShareRecipientType` enum had drifted behind the ADR-0090 D3 + rename and the enforced runtime: the runtime expands `team` (via + `sys_team`/`sys_team_member`) and `business_unit`, but the authoring enum + still offered the pre-rename `group` (silently skipped at seed time) and + omitted the two live recipients. After this change **every authorable + recipient and rule type is enforced** — nothing on the SharingRule surface + validates and then silently does nothing (ADR-0078). + + - **`sharedWith.type: 'group'` → `'team'`** (wire-rename): the enum member is + renamed to match the runtime vocabulary and now maps through the seed + bootstrap to the live `TeamGraphService` expansion. Flat `sys_team` + membership; enforced. + - **`business_unit` added** to the authoring enum — exactly one business + unit's members (no subtree; use `unit_and_subordinates` for the subtree). + The runtime + bootstrap already enforced it; only the enum omitted it. + - **`guest` removed** — it had no runtime recipient mapping. Anonymous access + is served by the public-form grant and share links, not sharing rules. + - **Owner-type rules removed** (`type: 'owner'`, `ownedBy`, + `OwnerSharingRuleSchema` + its type export): they depend on live + team/position membership, which the static materialiser cannot track, so + they validated but never materialised a share. They return as an enforced + form if membership-reactive re-materialisation is designed. + `SharingRuleSchema` is now the criteria form; the `queue` recipient stays + runtime-reserved (no `sys_queue` yet) and deliberately non-authorable. + + **Migration** (stale definitions now fail parse with the valid options listed): + + - `sharedWith: { type: 'group', … }` → `sharedWith: { type: 'team', … }`. + - `sharedWith: { type: 'guest', … }` → delete the rule; expose the records + via a public form or share link instead. + - `type: 'owner'` rules → rewrite as a `type: 'criteria'` rule scoping the + rows by field values (see the migrated examples: + `share_open_tasks_with_manager` in app-showcase, + `share_active_leads_with_manager` in app-crm), or use a scope-depth grant. + +### Patch Changes + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- 647ec8b: fix(driver-sql,sharing): an unsortable query loses its ORDER BY, not its rows (#3821) + + `SqlDriver.find()` already recovered from a SELECT projection naming a column + the table lacks (retry with `select('*')`, the unknown field is simply absent + from each row). The identical failure one clause over — an **ORDER BY** column + the table lacks — fell through to `return []`. Because `count()` is a separate + statement, the list endpoint answered `HTTP 200` with `records: []` and + `total: 3`: the rows are there, none are shown, nothing is logged. Same family + as the `$`-param footgun closed by #2926. + + It surfaced through the Console's sharing-rule **recipient picker**, which + never listed a single candidate. The client mangled `'name asc'` into + `0 n,1 a,2 m,…` (fixed separately in objectui) and the driver turned that into + "no users exist", so no sharing rule could be authored from the UI at all. + + Rows now outrank their order: the retry ladder drops the projection first (the + likelier culprit and the cheaper thing to lose), then the sort, then gives up. + A query that cannot be sorted comes back **unordered instead of empty**. Errors + that are not about an unknown column still propagate untouched. + + **A rule authored in Setup now actually applies — and switching it off actually + withdraws access.** Writing a `sys_sharing_rule` rebound the per-record hooks, + which only makes the rule reach records written FROM THEN ON. So an admin who + created a rule and enabled it saw nothing happen: the recipient's list stayed + empty until somebody happened to touch each record. The reverse was worse — + switching a rule OFF, or deleting it, left every grant it had already issued in + place, and boot backfill only reconciles ACTIVE rules, so those grants outlived + restarts while the UI displayed the rule as disabled. The reconcile was reachable + only through `POST /sharing/rules/:id/evaluate`, which the Console never calls. + + Each non-system write to `sys_sharing_rule` now also reconciles that rule's + grants, chained behind the existing rebind: insert/update run the same + diff-based `evaluateRule` the REST endpoint runs (it purges when the rule is + inactive), and delete purges directly via the new + `SharingRuleService.revokeRuleGrants` — `evaluateRule` can't help there because + the row is already gone (`RULE_NOT_FOUND`), which is also why a rule deleted + through the plain data API used to orphan its grants. Seeding and package + bootstrap write with `isSystem` and are skipped; `kernel:bootstrapped` already + backfills those. Reconciliation is best-effort and never fails the write. + + **The dialog's help text was engineering notes, shown to tenant admins.** The + field descriptions on `sys_sharing_rule` render under each input in Setup, and + they cited ADR numbers, table and column names (`parent_business_unit_id`, + `sys_business_unit`), enum machine values the dropdown never shows + (`business_unit`, `team`), a third-party library (better-auth), and engine + vocabulary ("evaluation", "lifecycle"). Several were also stale: they still told + admins to type an id or hand-write a `FilterCondition` after those inputs became + a record picker and a visual builder. Rewritten for the reader who actually sees + them — the implementation detail was already in the object's doc comment, which + is where it stays. `criteria_json`'s LABEL loses its "(FilterCondition JSON)" + suffix for the same reason, and `active` can finally say what it now does: + turning it off withdraws the access. + + Also refreshes the `sys_sharing_rule` help text in the zh-CN / ja-JP / es-ES + translation bundles, which still described `recipient_type` in terms of + `department` (the enum value is `business_unit`) and told admins to enter a + queue name for `recipient_id` (`queue` was removed in ADR-0078). The es-ES + option labels for `position` / `unit_and_subordinates` were translated as + "rol" — corrected to "Puesto" / "Unidad de negocio y subordinados". + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 73f8ba1ee8..01b89e7715 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "16.1.0", + "version": "17.0.0-rc.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 4678ff33ab..5fa62d57a7 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,249 @@ # @objectstack/plugin-webhooks +## 17.0.0-rc.0 + +### Minor Changes + +- 69f1dfd: fix(webhooks): materialize stack-declared webhooks into the dispatcher (#3461) + + A webhook authored declaratively — `defineStack({ webhooks })` / `defineWebhook()`, + validated against the spec `WebhookSchema` — was a **silent no-op**. The runtime + dispatcher (`AutoEnqueuer`) fans out off `sys_webhook` DATA rows (`object_name` / + `active`), which until now were only ever written by hand through the object's + CRUD UI. Nothing turned a declared webhook (`object` / `isActive`) into a + dispatchable row, so authoring `webhooks:` on a stack produced `webhook` metadata + that never fired (ADR-0078). The showcase app itself shipped a `webhooks:` entry + that did nothing. + + `@objectstack/plugin-webhooks` now bridges the two on boot: + + - **`bootstrapDeclaredWebhooks`** reads declared `webhook` metadata from the + ObjectQL registry (where the manifest decomposition already parks + `stack.webhooks`), validates each through `WebhookSchema.parse()` — the spec + schema finally has a real consumer — and materializes it into a `sys_webhook` + row, mapping `object → object_name`, `isActive → active`, and stashing the full + envelope (headers / secret / retry / timeout) in `definition_json`. The + auto-enqueuer's first cache refresh then picks the row up and dispatches it. + - **Seed-not-clobber provenance** (mirrors `sys_sharing_rule`, #2909): `sys_webhook` + gains `managed_by` / `customized` columns. Declared webhooks re-seed every boot + as `managed_by: 'package'`, but a row an admin created (`managed_by: 'admin'`) or + edited in Setup (`customized: true`, stamped by a `beforeUpdate` hook) is never + overwritten — a deactivated noisy webhook survives redeploys. + + Connector-declared `webhooks` remain not-yet-enforced (that is a separate seam, + #3197). Registering `webhook` as a first-class metadata type + enrolling it in the + liveness `GOVERNED` set is a tracked follow-up. + + Migration: none required. Existing hand-authored `sys_webhook` rows default to + `managed_by: 'admin'` and are never touched by the seeder. Anyone who authored + `webhooks:` on a stack expecting it to fire will find it now does — review those + declarations (especially `url` / `isActive`) before upgrading. + +### Patch Changes + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- 52281b0: chore(i18n): purge the dead sys_webhook_delivery translation block and guard against recurrence + + `sys_webhook_delivery` was removed when webhook delivery moved to + `@objectstack/service-messaging` (`sys_http_delivery`, ADR-0018 M3), but a full + translation block for it lingered in the four generated plugin-webhooks i18n + bundles (en/zh-CN/ja-JP/es-ES) — dead weight bound to an object that no longer + exists, and destined to be dropped silently (with any curated strings) on the + next `os i18n extract`. + + - Removed the stale `sys_webhook_delivery` block from all four locale bundles + (surgical; the `sys_webhook` block is untouched). + - Corrected three stale `sys_webhook_delivery` doc comments (platform-objects + `integration/index.ts` + `setup.app.ts`, plugin-webhooks `sys-webhook.object.ts`) + that still named it as a plugin-webhooks-owned object. + - Rolled out the platform-objects `bundle-ownership` test guard (#2834 ⑤ / + ADR-0029 D8) to the eight packages that own i18n bundles, so a stray object + block in a generated bundle now fails the build instead of dying silently. + - That guard immediately surfaced a live-object omission: `sys_capability` was + present in plugin-security's bundles with curated translations but had been + dropped from its extract config — re-added to the config so the strings are + preserved, rather than deleted. + +- c95ac80: chore(plugin-webhooks): drop the dead sys_webhook_delivery i18n blocks + + `sys_webhook_delivery` was removed from `@objectstack/plugin-webhooks` when + outbound delivery moved to `@objectstack/service-messaging` (`sys_http_delivery`, + ADR-0018 M3), but its translation blocks lingered in all four generated locale + bundles (en / zh-CN / ja-JP / es-ES) — loaded at runtime yet referenced by + nothing, since the object no longer exists in this plugin. + + - Removed the `sys_webhook_delivery` node from each `*.objects.generated.ts` + bundle; `WebhooksTranslations` now carries only `sys_webhook`. + - Corrected the stale ownership comment on `SysWebhook` that still named + `sys_webhook_delivery` as a live sibling. + + (The dangling `SysWebhookDelivery` import in `scripts/i18n-extract.config.ts` + was fixed independently on `main` by #3489, so it is not part of this change.) + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/service-messaging@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 419650f6c5..b48290dcdc 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "16.1.0", + "version": "17.0.0-rc.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 26ff455a4d..93f5578e95 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,192 @@ # @objectstack/dogfood +## 0.0.40-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [134df4f] +- Updated dependencies [fe67e34] +- Updated dependencies [3d3fddf] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [735f850] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [7101ca2] +- Updated dependencies [587fc91] +- Updated dependencies [415254c] +- Updated dependencies [1f8390b] +- Updated dependencies [3167e29] +- Updated dependencies [0a6fb1e] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [984396b] +- Updated dependencies [d0fea33] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [680e8e8] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f243727] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [2c19383] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [307e0fe] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [aff9e56] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [dac6a08] +- Updated dependencies [313d7be] +- Updated dependencies [5faeac6] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [fc5f126] +- Updated dependencies [adabaa8] +- Updated dependencies [030125b] +- Updated dependencies [605c23f] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [52281b0] +- Updated dependencies [db48ad5] +- Updated dependencies [8e08bc3] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [bd68f08] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [a629074] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [e889386] +- Updated dependencies [69f1dfd] +- Updated dependencies [c95ac80] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/service-storage@17.0.0-rc.0 + - @objectstack/plugin-auth@17.0.0-rc.0 + - @objectstack/plugin-security@17.0.0-rc.0 + - @objectstack/mcp@17.0.0-rc.0 + - @objectstack/service-analytics@17.0.0-rc.0 + - @objectstack/verify@17.0.0-rc.0 + - @objectstack/plugin-audit@17.0.0-rc.0 + - @objectstack/plugin-webhooks@17.0.0-rc.0 + - @objectstack/example-crm@4.0.92-rc.0 + - @objectstack/example-showcase@0.3.14-rc.0 + - @objectstack/connector-mcp@17.0.0-rc.0 + - @objectstack/connector-openapi@17.0.0-rc.0 + - @objectstack/connector-rest@17.0.0-rc.0 + - @objectstack/service-messaging@17.0.0-rc.0 + ## 0.0.39 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index d3fbca9b08..8f0abc59ed 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.39", + "version": "0.0.40-rc.0", "private": true, "license": "Apache-2.0", "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md index e58748b158..1e7da9ef65 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,127 @@ # @objectstack/downstream-contract +## 0.0.38-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 0.0.37 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index d72325fd5e..7802949431 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.37", + "version": "0.0.38-rc.0", "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", "license": "Apache-2.0", "private": true, diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md index 0fc0c0710b..71035687cb 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,15 @@ # @objectstack/http-conformance +## 0.0.6-rc.0 + +### Patch Changes + +- Updated dependencies [879ea13] +- Updated dependencies [a227ed7] +- Updated dependencies [763931e] +- Updated dependencies [4cca74c] + - @objectstack/core@17.0.0-rc.0 + ## 0.0.5 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 8fe60e50cf..5898f53ffb 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.0.5", + "version": "0.0.6-rc.0", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index ee8daf5ba4..3c3bf4af2b 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,1135 @@ # @objectstack/rest +## 17.0.0-rc.0 + +### Minor Changes + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- 3949a43: fix(metadata-protocol,rest): the data path really 404s unknown objects now (#3770) + + The REST API-exposure gate (`enforceApiAccess`) passes through any object it + cannot find in metadata, and the comment there justified that with + `// unknown object → let the data path 404`. That fallback did not exist. + + - `findData` — and every other data entry point except `cloneData` — had **no + existence check**. The repo's only `OBJECT_NOT_FOUND` throw was in `cloneData`. + - The engine does not reject unregistered names either: `resolveObjectName` + falls back to `StorageNameMapping.resolveTableName({ name })`, so the object + name is used **as the table name**. + - The 404 was therefore only ever a side effect of the **driver** erroring on a + missing table, which the REST layer recognised by matching the driver's error + string. + + So the 404 held only when the table happened not to exist. When a physical table + with that name **did** exist — out-of-band DDL, a registration that failed after + `syncObjectSchema` had already run, a registration race — the exposure gate was + silently skipped and the rows were served, with no layer turning it into a 404. + (Since #3545 an authenticated caller on a plugin-security deployment is refused + by the fail-closed posture check; anonymous callers and deployments without + plugin-security were not.) + + **The gate.** `ObjectStackProtocolImplementation` now runs a shared + `assertObjectRegistered` before storage is touched, on `findData`, `getData`, + `createData`, `cloneData`, `updateData`, `deleteData`, `batchData`, + `createManyData`, `insertManyData`, `updateManyData`, `deleteManyData` and + `analyticsQuery`. An object absent from the schema registry is rejected with + `OBJECT_NOT_FOUND` / 404 — an authoritative answer from the registry, raised + _before_ the name becomes a table name, instead of an inference from driver + prose. `cloneData`'s open-coded check is now that shared gate; its envelope is + unchanged. + + It sits at the protocol ingress, the same boundary `apiEnabled` guards: internal + callers (hooks, flows, migrations, raw ObjectQL) go to the engine directly and + are unaffected. When the engine exposes no schema registry at all there is + nothing to consult, so the gate stands down and warns once per process — + matching the tiering #3545 recorded in `api-exposure.ts` for a whole-registry + outage. + + **Behaviour change.** A REST data request for an object that is not in the + schema registry now returns `404 object_not_found` even when a table of that + name exists. Previously it returned that table's rows. If a deployment depended + on reading a table with no registered object, register the object (its schema is + what every other layer — exposure, RBAC/FLS/RLS, field projection — already + needs in order to enforce anything at all). + + **One wire code.** `mapDataError` maps the protocol's `OBJECT_NOT_FOUND` to the + canonical `object_not_found` `ApiErrorCode` — byte-identical to the envelope the + driver-string branch already produced — so a client keying on `code` sees _what + happened_, not _which layer noticed_. The driver-string branch stays as the + safety net for the other failure it actually covers: an object that IS registered + but whose physical table is missing. Callers that were reading `cloneData`'s 404 + as `code: 'OBJECT_NOT_FOUND'` on the wire now get `object_not_found`; the status + is 404 either way. + + The misleading comment is replaced with what actually closes the hole — this + gate for existence, plugin-security's `unresolved` posture (#3545) for + authorization — and a note not to widen the exposure gate on the assumption that + some other layer 404s. + +- 7c7e246: feat(authz): expose the caller's delegable scope — the read half of the + delegated-administration gate (ADR-0090 D12 / ADR-0105 D8) + + `adminScope` decided writes but could not be READ: `assignablePermissionSets` + lived only inside `delegated-admin-gate.ts`, so a UI offering "place this + person in a unit, with these positions" (the D8 scoped-invitation form) had no + way to narrow its pickers. It would list the whole tree and let the user + discover the boundary by being refused — which turns an authorization gate into + a validator and makes the boundary invisible until it bites. + + `ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as + `GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`: + + - `placeableBusinessUnitIds` — union of the subtrees where the caller may place + people (scopes granting `manageAssignments`); + - `assignablePositions` — positions whose every distributed permission set the + caller may hand out (containment check included); + - `scopes` — the held `adminScope`s with subtrees resolved, for attribution; + - `isTenantAdmin` — unconstrained, with everything enumerated so a consumer + renders ONE uniform picker instead of special-casing. + + Computed by the same helpers the write gate enforces with, so an option this + reports is one `assert()` accepts — a test asserts that agreement directly. It + NARROWS; the gate still decides. + + Strictly self-scoped: no target-user parameter, so it discloses nothing beyond + the authority the caller already holds (unlike `explain`, which has one and + gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with + no delegated authority gets empty lists, and a deployment without + `@objectstack/plugin-security` gets 501. + +- 9613396: feat(security): ENFORCE the user-level export axis on the server (#3544) + + `allowExport` landed as a spec bit plus a `/me/permissions` annotation, which + hid the client's Export button — and nothing else. Because `export ⊆ list`, the + REST export route streams through `findData` and the engine middleware sees an + ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller + holding `allowExport: false` could still `curl +/api/v1/data/:object/export` and drain the whole table. Declared, not enforced. + + - **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is + now a real decision: `export` = read granted ∧ not explicitly denied. + `allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map + means "the bit must be truthy", which would have denied export to every + permission set authored before the axis existed. The new exported + `resolveUserExportAllowed()` folds the tri-state across sets (`true` beats + `false` beats unset) exactly as the `/me/permissions` merge does. + - **spec** `ISecurityService` gains `canExport(object, context)` — the question a + bulk-egress door outside the engine middleware has to ask before it reads. + Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the + middleware. + - **rest** `GET /data/:object/export` calls it and answers **403 + `EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the + object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405 + says the object exposes no export, 403 says this caller may not use it. No + security service (no `plugin-security` ⇒ no permission sets) → allowed, the + same fail-open posture as every other permission gate in that layer; service + present but unable to answer → denied. + - **plugin-hono-server** the `/me/permissions` annotation now falls back to the + `'*'` entry's export bit when a per-object entry declares none, matching the + evaluator's own wildcard fallback — so a set that denies export wholesale via + `'*'` no longer offers a button the server refuses. + + Backward-compatible: `allowExport` is still an opt-out with no default, so an + unset bit inherits read and existing permission sets behave exactly as before. + Only a permission set that explicitly sets `allowExport: false` changes — and it + now changes on the server, which is the point. + + Implementers of `ISecurityService` outside this repo must add `canExport`; the + interface member is required, matching how `getReadableFields` was added. + Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a + partial implementation degrades rather than throwing. + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +- 16adb3c: fix(rest,client)!: reconcile the two REST↔client mismatches the #3587 audit + ledgered (#3610, #3611) + + **#3610 — `POST /api/v1/packages` publish-vs-install collision.** The REST + package registrar claimed the bare `POST /packages` for _marketplace publish_ + (`{manifest, metadata}`), while the dispatcher packages domain gives the same + verb+path _install_ semantics — and REST registers first in the production + stack (first-match-wins), so every `client.packages.install` call landed on + the publish handler and 400'd. Marketplace publish moves to + `POST /api/v1/packages/publish` (breaking for direct callers; a repo-wide and + objectui-wide sweep found zero). The dispatcher's `POST /packages/:id/publish` + (ADR-0033 draft publish) is two segments — different shape, no clash. The + dispatcher already writes both stores on install (`protocol.installPackage`) + and fully uninstalls on DELETE (`protocol.deletePackage`), so the remaining + REST GET/GET/DELETE shadows stay — they are compatible. + + **#3611 — UI view dialect split.** `meta.getView` spoke the `?type=` query + dialect that only the dispatcher `/ui` domain understands; the REST surface + mounts only the path form `/ui/view/:object/:type`, so the query form 404'd + wherever REST serves (e.g. project-scoped bases). The client now sends the + path form both surfaces accept; a URL-pinning test keeps it that way. + + REST route ledger updated: the two `mismatch` rows are resolved (packages + publish row is `server-only` publisher tooling; the ui row flips to `sdk`). + The ledger now carries zero mismatches. + +- bbd902d: feat(rest): unify request→environment resolution on the host's `kernel-resolver` seam — ADR-0076 D11 step ④ (#2462) + + The REST server kept its own parallel hostname/`X-Environment-Id` resolution + chain (duplicated inline in three places), while the HTTP dispatcher resolves + the same question through the host-injected ADR-0006 `kernel-resolver` seam — + so the same unscoped request could be attributed to different environments + depending on which HTTP surface served it. + + `RestApiPlugin` now adapts the host's `kernel-resolver` service (registered by + the cloud runtime next to `env-registry`; no cloud-side change needed) into a + new `RestRequestEnvResolver` seam, and `resolveRequestEnvironmentId` becomes + the single entry point every per-environment decision (protocol, i18n, + exec-ctx) flows through. Where a resolver is wired, its answer — including the + session-driven fallbacks the REST chain never had — is final; the legacy + built-in chain remains for OSS single-environment boots (no resolver + registered) and as the degradation path if the resolver throws. + +- 5ac93d4: feat(rest): surface silently-dropped write fields on PATCH/POST /data (#3431) + + #3413 (closes #3407) built the engine-level strip-observability channel + (`WriteObservabilityOptions.onFieldsDropped`) and wired the flow side + (`update_record` / `create_record` emit a step warning + `droppedFields`). The + **REST write path was never wired**, so an external API caller writing N fields + still got a bare `200 + record` when `readonly` (#2948) / `readonlyWhen` (#3042) + stripping meant `< N` actually landed — the same silent-success class #3407 + fixed flow-side, just on HTTP. The only way to notice was a per-field diff of + the returned row (which need not echo every field). This wires the channel + through the protocol → REST, on both write verbs. + + **Passthrough (metadata-protocol).** `updateData` now registers an + `onFieldsDropped` collector on `engine.update` and returns the events on the + response as `droppedFields`. `createData` surfaces the #3043 static-`readonly` + INGRESS strip too — that strip runs at the protocol ingress + (`stripReadonlyForInsert`), _before_ the engine, so it is recovered by diffing + the supplied payload against the stripped one (the engine's `onFieldsDropped` is + also wired for a future insert-side engine strip). A faulty listener never + breaks the write — the engine catches and logs. + + **Contract (spec).** `UpdateDataResponseSchema` / `CreateDataResponseSchema` + gain an **optional** `droppedFields: DroppedFieldsEvent[]` — present only when + ≥1 field was dropped. Optional + omit-when-empty keeps the response shape + backward-compatible for clients that only read `record`. + + **REST surface.** PATCH `/data/:object/:id` and POST `/data/:object` echo the + drops as an `X-ObjectStack-Dropped-Fields` response header + (`field;reason=` tokens, comma-joined — e.g. + `approval_status;reason=readonly`) and keep the structured `droppedFields` on + the body. **Status/success semantics are unchanged** (200 update / 201 create) — + a strip is legitimate semantics, not a failure (same principle as #3413). The + FLS write gate is untouched (it already fails closed with 403). + + Out of scope (issue #3431 D2 open questions, deferred): bulk + (`updateManyData` / `createManyData` / `batchData`) and GraphQL mutation wiring, + typed `@objectstack/client` warnings, and adding the header to the Hono CORS + `exposeHeaders` allow-list for cross-origin browser reads (the body + `droppedFields` is the cross-origin-safe channel meanwhile). + +- d318b24: feat: `security.getReadableFields` query surface for export column projection (#3547, #3391 follow-up) + + The REST export route projected its columns by inferring readability from the + first chunk of already-masked data rows (#3498). That has two known + compromises: a readable column whose first-chunk values are all null (and thus + omitted by the driver) drops out of the header, and an empty result set leaves + nothing to narrow. This adds the long-term-correct path. + + - **plugin-security** — the `security` service gains + `getReadableFields(object, context)`. It resolves the caller's permission + sets and builds the field-permission map with the SAME evaluator + + `requiredPermissions` fold the read middleware's `FieldMasker` uses (and the + same on-behalf-of delegator intersection, fail-closed on a dangling + delegator), then returns every schema field NOT masked non-readable — the + exact complement of what the mask deletes, so it can never drift from + data-plane FLS. Computed from schema + context, never from data rows: immune + to null values and empty result sets. A system context bypasses FLS; an + unresolvable schema returns `undefined` so callers fall back. + - **rest** — the `GET /data/:object/export` route asks the environment's + `security` service for `getReadableFields(object, context)` and projects the + schema-derived header to that set BEFORE streaming. When no security service + is reachable (no plugin-security / single-kernel without a provider) it + degrades to the existing masked-row inference, so there is zero regression. + Explicit `?fields=` requests are still honored verbatim. + + Contract-neutral: export columns already equal list's readable columns + (`export ⊆ list`, #3391); this makes the projection authoritative instead of + inferred. + +### Patch Changes + +- fa3d0cf: feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) + + `@objectstack/spec/data` now owns the runtime VALUE shape of every field type + (`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`, + `NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`, + `STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared + `isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The + four consumers that each hand-copied this knowledge (objectql record-validator, + rest import-coerce, driver-sql column classification, qa conformance) now + derive from the spec, and the field-zoo round-trip MATRIX is asserted against + the contract so the two cannot drift. + + **Write-path change (objectql, warn-first):** previously-unvalidated types — + single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/ + `video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`, + `vector` — are now checked against the contract. A violation **logs a warning + and passes** in this release (legacy rows must not strand their records); + set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a + `400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor + (ADR-0104 R1/R2). + + **Deprecations (removal rides the next spec major), FROM → TO:** + + - `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's + value is a **bare number** everywhere in the runtime (validator, SQL `float` + column, import coercion, field-zoo oracle); the currency code lives in field + config. Use `valueSchemaFor({type: 'currency'})`. + - `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema` + (`{lat, lng}`) — the shape the platform actually stores. + - `AddressSchema` is **adopted** (unchanged) as the enforced `address` value + contract via `AddressValueSchema`. + + No stored data changes shape; the contract codifies deployed reality + ("reality wins", ADR-0104 D1). + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) + + Two independent defects on the `/analytics` surface, found while verifying #3770 + against a real server. On an authenticated CRM dev server, before this change: + + ``` + POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} + → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}], + "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"} + ``` + + That is SQLite's internal schema table — never a registered object — read + successfully through the analytics endpoint. Not merely "the name reaches the + driver and errors": **any table the connection can see was readable.** + + **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube` + auto-infers a minimal Cube when none is registered, with `cube.sql = `. That is the intended "metric over an object" path — an `object-metric` KPI + widget queries `crm_account` with no authored Cube — but it accepted _any_ string, + so the endpoint could aggregate over an arbitrary physical table. The + analytics-side twin of the data-path gap #3770 closed, and it was not covered by + that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded + fallback_; a deployment with `@objectstack/service-analytics` installed runs the + real engine instead (`ctx.replaceService`). + + Inference is now gated on the same schema registry the data path consults, via a + new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires + from the `data` engine's `getObject`. Three-way rule: a registered Cube runs + untouched (its `sql` is whatever it declares); an unregistered name that IS an + object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404 + raised before any SQL exists, naming both ways to make the request valid. With no + probe configured the gate stands down and warns once — the same tiering #3770 + took for a missing registry. `generateSql` (`/analytics/sql`) is gated too. + + **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase` + is the single error exit for _every_ route the dispatcher plugin mounts — + `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, + `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against + driver dumps forever (`mapDataError`); this boundary guarded nothing, so any + driver error on any of those routes shipped its SQL to the client. Unlike ①, this + half is unconditional — it does not depend on the cube being invalid. + + The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as + `looksLikeInternalErrorLeak` (both packages already depend on it) and is now + applied at both boundaries — one predicate, one place to widen when a new + dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the + dispatcher it applies **only to 5xx**: a 4xx message is a deliberate + business/validation answer and must reach the caller intact. Sanitising costs no + diagnostics — the untouched error still reaches `errorReporter` through the + existing `__obsRecordedError` side-channel. + + **Also fixed in the same function:** `errorResponseBase` read only + `err.statusCode`, while domain errors across this codebase carry `status` (and + `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate + 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on + the analytics fallback path — was rendered as a **500**. It now reads `status` + then `statusCode`. + + **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404 + `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object; + previously the name was passed to the driver. Dashboards and KPI widgets pointed + at real objects or authored cubes are unaffected. A 5xx on a dispatcher route + whose message looks like a driver dump now reads `Internal server error` — check + server logs or your error reporter for the original. + +- 1986594: feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588) + + Three presentation options were accepted by the metadata layer and then dropped + by the analytics query builder. They reached no SQL, produced no error, and the + only way to notice was to read the `sql` a dataset response echoes — so a + dashboard could declare `dateGranularity: 'month'` and quietly render one bar + per record. + + - **`dateGranularity` now buckets.** `DatasetSelection` gained an optional + `dateGranularity`, applied to every selected `date` dimension. Precedence per + dimension: an explicit `timeDimensions` granularity, then the selection's, + then the dataset dimension's own default. A widget can bucket a trend by month + without the dataset committing every other consumer to that granularity. + - **`order` / `limit` / `offset` now apply on every path.** They are applied to + the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo` + columns attach, and after derived measures are computed — so a derived measure + is a valid sort key and the ObjectQL aggregate path (which has no ordering + grammar, and which native SQL hands every date-bucketed query to) orders + identically to native SQL. A single-query selection still pushes the window + down into the statement. An `order` key that names nothing the selection + projects is now rejected (400) rather than silently ignored. + - **`limit` is deterministic.** Without an `order`, a limit orders by the + selected dimensions first, so it truncates a reproducible window instead of an + arbitrary subset. + - **Widget `options` is a contract again.** The four query-affecting keys + (`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are + declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is + an author-time error. The bag stays open — renderer extras (`icon`, `columns`, + `striped`, …) pass through untouched. + + Two latent bugs surfaced while fixing the above and are fixed here too: + + - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped + supplementary query selects one measure, so an inherited `ORDER BY` named a + column it never selected, and an inherited `LIMIT` truncated it before the + merge — dropping rows from the assembled grid. Nothing hit this only because + nothing passed `order`. + - The `compareTo` pass built its query by hand and skipped granularity + resolution, so a month-bucketed primary grid was merged against raw-timestamp + comparison rows. No dimension key matched and every `__compare` + column came back empty. + + `ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`, + `WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined). + Previously the `sql` field simply vanished from the response whenever a query + was date-bucketed, leaving an author unable to tell "not implemented" from "this + strategy doesn't report". + +- 3c8cfd1: fix(rest): make the API-exposure gate's metadata fail-open observable (#3545, #3391 follow-up) + + The object API-exposure gate (`apiEnabled` / `apiMethods`) fails OPEN when object + metadata can't be resolved, so a transient metadata outage doesn't 405 every + request. #3545 evaluated the residual risk of that path and confirmed it is + acceptable — the gate is a **surface-area control, not the authorization + boundary**: every request still passes auth and the ObjectQL security middleware + (CRUD / FLS / RLS) on the data call regardless of the gate's outcome, so a + fail-open can never bypass data authorization. + + The one gap was that the fail-open was **silent** — a persistent metadata fault + (store down / corrupt schema doc), during which the gate allows every operation + unchecked, looked identical to healthy operation. + + - **rest** `loadObjectItems` now LOGS a _thrown_ metadata read (a real fault) + while leaving a legitimately-empty registry (a cold-start `[]`) silent — so a + genuine outage is diagnosable without false alarms during normal startup. The + behavior is unchanged (still returns `[]` → gate abstains → data path + security + enforce). + - **runtime** `api-exposure.ts` records the #3545 tiered decision in its + contract doc: keep fail-open when the whole metadata service is unavailable + (failing closed would break the cold-start window for no security gain); the + narrow "object resolvable but its `enable` policy is present-yet-unreadable" + widen (unreachable through Zod-validated registration) is deferred to the + exposure-semantics window (#3543). + + No contract or behavior change to the gate itself — observability + decision + record only. + +- f92096b: fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800) + + Every mutating approvals entrypoint takes an `actorId`, and the REST routes + filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body + won. The service then authorized _that value_: `pending_approvers.includes( +input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never + checked that the value named the caller. + + So any authenticated user could POST `{"actorId": ""}` and have + that person's approval recorded, the request finalized, and the owning flow run + resumed down the `approve` edge — or name a request's submitter and recall it. + With `api.requireAuth` unset the anonymous-deny never fires either, so an + unauthenticated request could do the same. + + #3783 drew this line for the _data-write_ identity and called the audit-row half + "tolerable". It was not: the same unchecked string was the authorization key, so + naming someone else was not a mislabelled audit row, it was how you got through + the door. + + The actor is now resolved server-side (`ApprovalService.resolveActor`) on all + nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`, + `reassign`, `remind`, `requestInfo`, `comment`. + + **The rule is not "`actorId` must equal `context.userId`."** A slot can + legitimately be keyed by something else: the approver resolver stores the + `type:value` literal when a graph lookup finds no holders, and the Console picks + from the caller's own identity list — user id, email, or `role:`. The rule is + **"the actor must be an identity the server can prove belongs to the caller"**: + + - A **system** context keeps its explicit actor. The SLA sweep's reserved + `system:sla` sentinel and the ADR-0043 action link — whose single-use hashed + token binds exactly one approver — are unchanged. They are the only callers + holding a trustworthy actor with no session behind them. + - A caller with **no identity at all** is now refused. This is the anonymous case + above. + - **No `actorId`, or one naming the caller**, resolves to the caller. This is the + common path and what the Console already sends. + - **Any other value** is accepted only when the server can prove the caller holds + it — `position:

` / `role:

` against the positions on the resolved authz + context, or the caller's own email (one lazy `sys_user` read, taken only when + nothing cheaper matched). Otherwise `FORBIDDEN`. + + REST still forwards the body value; it is now a _hint_ the service validates, + which is what keeps the email and `type:value` slot cases working. + + **Upgrade note.** A client that deliberately sent another user's `actorId` now + gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the + acting user's own session — the field can be omitted entirely, and the caller is + used. Server-to-server callers that legitimately act for someone else should + present a system context, as the SLA sweep and the action link already do. + + This also makes two existing claims true that were previously aspirational: the + approval object's declared actions say "`actorId` defaults to the caller + server-side… the service remains the authority on who may act", and + `attachViewers` documents `can_act` as mirroring "the exact authorization the + decision methods enforce". + +- 1003125: feat(client): close the approvals (6) + record-shares (3) REST gaps (#3587 batch 3/5) + + `client.approvals` gains the full request lifecycle beyond approve/reject: + `recall` (submitter withdraw), `revise` / `resubmit` (ADR-0044 send-back + round-trip), and the thread interactions `remind` / `requestInfo` / `comment`. + New `client.shares` namespace for per-record sharing grants: `list` / `grant` / + `revoke` (204-safe) under `/data/:object/:id/shares`. REST route-ledger + ratchet: 26 → 17. + +- 6e62a93: feat(client): close the sharing-rules (5) + security-explain (2) + search (1) REST gaps (#3587 batch 4/5) + + New `client.shares.rules` sub-namespace for tenant-wide sharing rules + (M10.17): `list` / `save` / `get` / `delete` (204-safe, grants cascade) / + `evaluate` (reconcile). `client.security.explain` speaks the ADR-0090 D6 + access-explanation contract via the POST transport (the GET query form is the + same `ExplainRequestSchema`). Top-level `client.search` covers global + cross-object search (M10.5). REST route-ledger ratchet: 17 → 9. + +- ecda20c: feat(client): close the 8 reports-family REST gaps (#3587 batch 2/5) + + New `client.reports` namespace speaking the plugin-reports REST surface: + `list` / `save` / `get` / `delete` (schedules cascade), `run`, `schedule`, + `listSchedules`, `unschedule`. The two DELETE routes return 204 — the client + methods return `{ deleted: true }` without attempting to parse an empty body. + Fixed path (`/api/v1/reports` is not in `ApiRoutesSchema`), matching the + keys / share-links precedent. REST route-ledger ratchet: 34 → 26. + +- 6e62a93: feat(client): close the final 9 REST gaps — ratchet 9 → 0 (#3587 batch 5/5) + + `data.clone` (enable.clone duplication) and `data.export` (streaming + CSV/JSON/XLSX; returns the raw `Response` — a file stream, not a JSON + envelope). New `email.send` (IEmailService; branch on the returned `status`). + `analytics.queryDataset` speaks the ADR-0021 REST dataset-query dialect. New + `datasources.external.*` federation admin: `listTables` / `draft` / `import` / + `refreshCatalog` / `validate` (ADR-0015 Addendum, 503-degrading). Every REST + route is now either SDK-expressed or carries a reviewed non-sdk disposition — + the #3587 gap ratchet rests at ZERO. + +- fc968af: feat(client): close the 9 metadata-family REST gaps the #3587 ledger carried (#3587) + + New `meta` surface: `getDiagnostics` (spec-validation sweep), `getReferences` + (reverse references), `getBookTree` (ADR-0046 §6 spine resolution), `getAudit` + (ADR-0010 §3.6 protection trail), `publishItem` / `rollbackItem` / `diffItem` + (ADR-0033 per-item draft lifecycle). The two compound-name routes + (`GET|PUT /meta/:type/:section/:name`) turned out to be already expressible — + `getItem`/`saveItem` pass slashes through unencoded — so they are flipped to + `sdk` with URL-pinning tests instead of new methods (the audit note claiming + an encoding barrier was wrong; only `deleteItem` encodes). REST route-ledger + ratchet: 43 → 34. + +- 48c110e: feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) + + #3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two + gaps survived that fix, both in the cases that still boot — a policy denial, an + `autoConnect` datasource, or any failure the operator waved through with + `OS_ALLOW_DRIVER_CONNECT_FAILURE`: + + - **It was invisible.** `DatasourceSummary.status` was the literal `'unvalidated'` + for every row — the contract declared three states and the implementation only + ever emitted one — so a dead datasource looked exactly like a healthy-untested + one. `checkDriversHealth()` could not help either: it iterates registered + drivers, and a datasource that never connected was never registered, so it is + _absent_ from the probe rather than unhealthy. The only trace was a warning + that scrolled past at boot, which made the diagnostic procedure "restart the + server and re-read the logs". + - **The query-time error said nothing.** `getDriver()` answered four different + situations with one sentence, `Datasource 'x' is not registered.`: refused by + policy, failed to connect under the escape hatch, a misspelled name, and + `active: false`. Only the third is an authoring bug, so the other three sent + the reader hunting for a typo that does not exist. + + Both come from the same root: `connect()` already produced a `ConnectResult` for + every attempt and every caller threw it away. + + - **`DatasourceConnectionService` retains the last verdict per datasource**, with a + coarse `availability` (`available` / `blocked` / `failed` / `unattempted`) beside + the raw status. New `getConnectionState(name)` / `listConnectionStates()`. + `disconnect()` drops it, so a removed pool stops explaining itself. + - **`DatasourceSummary.status` tells the truth**: `ok` | `error` | `blocked` | + `unvalidated`, with a new operator-facing `statusReason`. `blocked` is new and + deliberate — a policy denial is a decision, not a fault, and will not clear on + its own. Reported in **Setup → Datasources**, `GET /api/v1/datasources`, and the + summary returned from create/update, so a "Save" whose pool failed to open is no + longer presented as success. + - **`ERR_DATASOURCE_UNAVAILABLE` (HTTP 503)**: new `DatasourceUnavailableError` + from `@objectstack/objectql`, thrown by `getDriver()` when the connection layer + recorded _why_ a declared datasource has no driver. An undeclared name keeps the + original message — there is genuinely nothing to add. 503 rather than 500/400: + nothing about the request is wrong, and the state may clear. + - **A privileged/public split for the reason.** The error **never** carries the + underlying cause — connect failures routinely contain hosts, ports and DSNs, and + a policy's `reason` is written for operators. Those stay in the logs and the + (admin-gated) datasource list. `DatasourceConnectDecision` gains an opt-in + `publicReason` for hosts that want to tell tenants something specific + (e.g. `'External datasources require the Scale plan.'`); it is the only string + that reaches an end user. + - **Readiness is deliberately not gated on this.** `/ready` still reflects + registered-driver health only: an optional datasource being down must not pull an + otherwise-working replica out of the load balancer. + + Also lands a drift guard for **#3826**, and corrects ADR-0062's status while doing + it. The ADR claimed D1 ("exactly one definition → live driver path") as + implemented; only the _construction_ half converged. The `default` driver is still + registered as a `driver.*` kernel service and connected by `ObjectQLEngine.init()`, + with its own failure verdict, pool teardown, and no connect policy. What blocks the + merge is an input-shape mismatch, not ordering: `connect()` takes a datasource + _definition_ and builds the driver, while `default` arrives pre-built, and routing + it through the service would make `ObjectQLPlugin`'s boot depend on an optional + higher-layer service. Until that is designed, `degraded-boot-parity.test.ts` pins + both paths to the same operator-visible contract (fail-fast by default, identical + `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr) so a change + to one that forgets the other fails CI — #3741 → #3758 was exactly that miss, and + it cost three months and a second bug report. + + **Migration.** Additive. `DatasourceSummary.status` gains a `'blocked'` member: a + consumer exhaustively switching on it needs a case (the admin UI shows it as a + distinct state). Nothing that was `'ok'` or `'error'` changes meaning; rows that + were reported `'unvalidated'` now report their real state. Query-time errors for a + datasource the connection layer recorded change from a generic `Error` to + `DatasourceUnavailableError` (503 instead of the previous catch-all status); + matching on the old `is not registered` text still works for the undeclared-name + case, which is the only one that was ever accurate. + +- ce1f100: fix(rest): export emits the projected header row on an empty result set (#3547) + + `GET /data/:object/export` wrote a zero-byte file whenever the query matched no + rows — the header was only ever written alongside the first data chunk. With the + `getReadableFields` column projection the readable column set is derived from + schema + context, so it is known even when no rows come back: an empty CSV/xlsx + export now carries the exact readable header, which also makes it a usable + import template. + + The header is emitted only when the column set is AUTHORITATIVE — the security + service's readable projection, or an explicit `?fields=` request. When the header + is schema-derived and the projection was unavailable, the export stays headerless + as before: the masked-row fallback has no rows to narrow with, and writing the + full schema header would name FLS-hidden columns. `header=false` still suppresses + the header in every case. + +- 81ce41a: feat(rest): `treatAsHistorical` import also preserves the original audit timeline (#3493) + + Follow-up to #3479/#3483. `treatAsHistorical` solved the FSM half — mid-lifecycle + rows are no longer rejected by `initialStates` — but the OTHER half of a historical + migration, preserving the original timeline, still didn't hold: an imported ticket + that closed in 2021 stored `updated_at` = the import day (and `updated_by` = the + importer), and a `writeMode: 'upsert'` refresh silently dropped business `readonly` + fields (`closed_at`, `resolved_by`). Reports, audit, and "recently modified" + sorting all came out wrong. + + Three layers were force-overwriting the timeline; all three now respect a single + new opt-in flag, `ExecutionContext.preserveAudit`, which `treatAsHistorical` sets + alongside `skipStateMachine`: + + - **spec**: `ExecutionContext.preserveAudit` (server-set only, never client-supplied) + and `DriverOptions.preserveAudit` (threaded to the driver's update stamp). + - **objectql** — the built-in audit hook (`plugin.ts`) now treats `updated_at` / + `updated_by` as CLIENT-PREFERRED (`?? now` / `?? userId`) under `preserveAudit`, + symmetric with how `created_at` / `created_by` already behave on insert; and the + static-`readonly` write strip (`stripReadonlyFields`) admits a WHITELIST — the + audit/timestamp family plus author-declared business `readonly` fields — so an + upsert refresh no longer drops them. + - **driver-sql** — the SQL `update` path keeps a supplied `updated_at` instead of + force-advancing it to `now` when `DriverOptions.preserveAudit` is set (fills-only- + empty, mirroring the insert stamp). + - **rest** — the import runner sets `preserveAudit` on the write context iff the + request opts into `treatAsHistorical`. + + Deliberately a WHITELIST, not the blanket `isSystem` exemption: platform-managed + `system` columns OUTSIDE the audit family (`organization_id` / tenancy, generated + columns) STAY stripped, so a historical import reinstates established facts without + becoming a backdoor to forge tenancy. Permissions / RLS / field-level security are + unaffected — this changes only which audit/readonly values the runtime overwrites, + never who may write the record. Fully opt-in: a normal write still auto-stamps + `updated_at`/`updated_by` and strips `readonly` exactly as before. The objectui + "Import as historical data" checkbox (objectui#2815) now drives both halves — no new + UI. + +- 85e1e4e: feat(rest): `treatAsHistorical` import option — skip the state machine for historical-data migration (#3479) + + Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced + the FSM entry point on every INSERT, so importing established historical facts — + a batch of already-`closed` tickets, `closed_won` deals, `completed` projects — + was rejected row-by-row with `invalid_initial_state`, blocking the core + data-migration path. Unlike the seed case it was visible (per-row errors), but it + still functionally blocked a legitimate use. + + - **spec**: `ExecutionContext.skipStateMachine` — a general, server-set flag (the + seed-specific `seedReplay`'s sibling) that skips the `state_machine` rule for a + write; `ImportRequestSchema.treatAsHistorical` (default `false`) — the user-facing + import option. + - **objectql**: the engine now skips the state machine for `seedReplay` OR + `skipStateMachine` (one helper), covering both seed replay and historical import. + - **rest**: the import runner sets `skipStateMachine` on the write context iff the + request opts into `treatAsHistorical`; default off, so a normal import still walks + the FSM (the strict behavior is the default). Import **undo** now also carries + `skipStateMachine`, since restoring a prior snapshot re-writes an earlier state + that need not be a legal transition from where the row is now. + - **platform-objects**: `sys_import_job.treat_as_historical` audit column (additive). + + Scope is identical to the seed exemption: ONLY the `state_machine` rule is skipped; + field shape, `format`, `cross_field`, `script` all still run. The objectui import + wizard checkbox is a separate follow-up. + +- 65ac468: fix(import): sanitize row errors — never leak raw SQL, map constraint failures to human wording (#3566) + + A failing import row surfaced the driver's raw error verbatim. When a write hit + a DB constraint (e.g. `sys_user.phone_number` is `unique`), the query builder + embeds the entire failing statement in `err.message`, and `toFailedResult` + handed that straight back — so the importer saw `` insert into `sys_user` +(...) values (...) - UNIQUE constraint failed: sys_user.phone_number ``. That is + both unreadable and an information disclosure of the schema. + + - `sanitizeRowError()` (import-runner) maps the common constraint failures — + SQLite / MySQL / Postgres `UNIQUE` and `NOT NULL` — to human wording + ("A record with this `` already exists.", "`` is required.") + and, as a backstop, never lets a message that still reads as a SQL statement + reach the client (it salvages the driver's trailing reason, or falls back to + a generic message). Already-friendly messages (e.g. better-auth's "User + already exists") pass through unchanged. Applies to every import path. + - `isLikelyEmail` now rejects non-ASCII addresses, so an address like + `x@柴仟.com` fails the import **dry-run** pre-check instead of passing client + and dry-run validation only to be rejected by better-auth's strict ASCII + validator at real-import time. + +- ef5e72d: fix(rest): undo of a historical import now preserves the audit timeline (#3549) + + A `treatAsHistorical` import writes with `preserveAudit` (#3493), keeping the + original `updated_at`/`updated_by` and business `readonly` fields instead of + stamping-now / stripping them. Its undo route, however, restored the captured + pre-import snapshot with a plain write context — so the audit auto-stamp + re-wrote `updated_at`/`updated_by` to "now", silently corrupting the very + timeline the historical import had preserved. + + The undo write context now mirrors the import's own: it carries + `preserveAudit` iff the job row is flagged `treat_as_historical`, so restoring + `u.before` re-writes the snapshotted audit/timestamp values verbatim. A normal + import's undo is unchanged (default stamp/strip). + +- 67452d1: feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589) + + Custom system pages authored as metadata (Installed Apps, Cloud Connection, + Connect an Agent) hard-code their `page:header` copy in + `properties.title` / `properties.subtitle`. Every other metadata type is + localized at the REST boundary, but `page` was not: the `pages` namespace + existed only on `AppTranslationBundleSchema` — a schema no runtime reads — + with no resolver behind it, so those headers stayed English in every locale + while the matching nav labels translated correctly. + + - `TranslationDataSchema` (the shape the i18n service actually serves) gains a + `pages` namespace: `pages..{label,description,title,subtitle}`. + - New `translatePage` in `@objectstack/spec/system` translates a page's own + `label` / `description` and overlays `title` / `subtitle` onto every + `page:header` in the page's regions. Registered in + `translateMetadataDocument`, so it rides the existing read path. + - `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale + extraction, the locale-keyed ETag, and `Vary: Accept-Language` already + covered every metadata type — no new plumbing. + - `objectstack i18n extract` now emits page entries, including the + `page:header` copy, so the new namespace is not invisible to the tooling. + - zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus + the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these + existed only in zh-CN). + + Header copy is keyed by **page name**, not by component id: `page:header` + instances carry no stable id. `title` falls back to `pages..label`, since + a page's header title and its nav label are normally the same string. + + Authoring is unchanged and English literals stay in metadata as the fallback — + a page with no `pages` entry renders exactly as before. Consumers of + `@object-ui` need no change: pages arrive already localized from the server. + +- 3d5f726: feat(rest): route audit tranche 2 — the REST surface gets its own ledger + + conformance guard (#3587, follow-up to #3563) + + The dispatcher tranche closed its 27 gaps and guards them (#3569…#3579), but + `@objectstack/rest` mounts a second, larger surface the client also reaches — + 89 routes, never audited. `rest-route-ledger.ts` now records a reviewed + disposition for every one of them (38 sdk, 43 gap, 3 server-only, 3 public, + 2 mismatch), and the guard is real enumeration on both sources: RouteManager + routes via the `getRoutes()` introspection seam, and the two + RouteManager-bypassing registrars (`package-routes.ts`, + `external-datasource-routes.ts`) via captured mock-server registrations — no + pinned-by-hand list. The client half + (`rest-route-ledger-coverage.test.ts`) verifies every claimed method exists; + a 43-gap ratchet is wired into CI. Every guard direction was negative-tested. + + Notable dispositions the audit surfaced: `POST /api/v1/packages` is a + publish/install shape collision between REST and the dispatcher (REST + registers first and wins) — ledgered `mismatch`; the REST + `GET /ui/view/:object/:type` path dialect is unreachable by the SDK's + query-param dialect — ledgered `mismatch`; `service-storage` / + `service-i18n` mount a third route surface outside `@objectstack/rest`, + explicitly out of scope here and tracked under #3587. + + No behavior change — data + tests only, plus a scope-note refresh in the + runtime ledger pointing at the new REST ledger. + +- 1659072: feat(spec): publish `ISecurityService` — the `security` service surface becomes an enforced contract + + The `security` service registers seven cross-package methods (`getReadFilter`, + `getReadableFields`, `resolvePermissionSetNames`, `explain`, and the three + audience-binding suggestion calls) but had no contract in + `@objectstack/spec/contracts`. Consumers duck-typed it, and each one invented its + own fallback for a missing method or an "empty" answer — with more consumers + arriving, that is a drift surface. + + `ISecurityService` now documents the surface, and both ends are typed against it + so it is **enforced rather than declared**: `plugin-security` assigns its + registration to `ISecurityService` (a renamed, dropped, or re-typed method fails + that build), and the REST layer resolves the service as a `Partial` + (so call sites must keep feature-detecting instead of assuming the full surface). + + The contract makes explicit the one thing consumers cannot guess — that the + methods do **not** share a failure convention: + + - `getReadFilter` fails **CLOSED**: a resolution failure yields a deny filter + matching zero rows, never `undefined`. `undefined` means "no row restriction", + and nothing else. + - `getReadableFields` fails **SOFT**: `undefined` means "no answer, use your own + projection", while `[]` is authoritative and means "no field is readable" — + opposite instructions that a consumer must not conflate. + + Typing the producer immediately caught one real discrepancy, fixed here: + `getReadFilter` declared `Promise | null | undefined>` + while every return path yields a filter or `undefined` (`filter ?? undefined` + normalizes the null away). The dead `| null` is removed, so "no restriction" has + exactly one representation. Type-level only — no runtime behaviour changes. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + - @objectstack/service-package@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index c518d9d951..9100b10546 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "16.1.0", + "version": "17.0.0-rc.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 3f351169df..25ba0d3c4b 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,1582 @@ # @objectstack/runtime +## 17.0.0-rc.0 + +### Minor Changes + +- af5a224: feat: enforce declared action-param contract at dispatch — ADR-0104 phase 2 (D2) + + An action's declared `params[]` (`type` / `required` / `multiple` / `options` / + `reference`) was a complete value contract that only ever informed the client + dialog — the server passed `reqBody.params` straight to the handler unvalidated + (REST `handleActions` and the MCP `invokeBusinessAction` path), and handlers + read an untyped bag. D2 makes the declaration enforced and typed. + + - **`@objectstack/spec/ui`** now exports `validateActionParams` (+ + `ResolvedActionParam`, `ActionParamIssue`, `ACTION_PARAM_BUILTIN_KEYS`): a + pure check that validates a params bag against resolved param declarations, + reusing the D1 `valueSchemaFor` so option membership, `multiple` arrays and + reference-id shape all ride the one value contract. Also exports the typed + authoring surface `ActionHandler` / `ActionHandlerContext` / + `ActionEngineFacade` — annotate a handler with `ActionHandler` instead of + `(ctx: any)`. + - **Dispatch (runtime)**: both the REST and MCP action paths resolve the + action's declared params (field-backed params resolved through the referenced + object field) and validate the request bag **before the handler runs** — + required presence, per-type value shape, and unknown keys (the dispatcher's + own `recordId` / `objectName` are allowlisted). + + **Warn-first rollout (ADR-0104 R3).** A violation is **logged and passes** by + default — params that were silently wrong before keep working while the drift + becomes visible. Set `OS_ACTION_PARAMS_STRICT_ENABLED=1` to reject with a + `400 VALIDATION` (REST) / an error (MCP). Actions that declare no `params` are + untouched (nothing to validate against). The flip to strict-by-default rides a + later minor once telemetry is quiet. + + Not included: file/image params becoming `sys_file` references — that depends + on file-as-reference (ADR-0104 D3). Per-name static typing of `ctx.params` from + the literal `params` array is a deferred DX nicety; the runtime guarantee holds + regardless. + +- 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) + + Two independent defects on the `/analytics` surface, found while verifying #3770 + against a real server. On an authenticated CRM dev server, before this change: + + ``` + POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} + → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}], + "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"} + ``` + + That is SQLite's internal schema table — never a registered object — read + successfully through the analytics endpoint. Not merely "the name reaches the + driver and errors": **any table the connection can see was readable.** + + **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube` + auto-infers a minimal Cube when none is registered, with `cube.sql = `. That is the intended "metric over an object" path — an `object-metric` KPI + widget queries `crm_account` with no authored Cube — but it accepted _any_ string, + so the endpoint could aggregate over an arbitrary physical table. The + analytics-side twin of the data-path gap #3770 closed, and it was not covered by + that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded + fallback_; a deployment with `@objectstack/service-analytics` installed runs the + real engine instead (`ctx.replaceService`). + + Inference is now gated on the same schema registry the data path consults, via a + new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires + from the `data` engine's `getObject`. Three-way rule: a registered Cube runs + untouched (its `sql` is whatever it declares); an unregistered name that IS an + object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404 + raised before any SQL exists, naming both ways to make the request valid. With no + probe configured the gate stands down and warns once — the same tiering #3770 + took for a missing registry. `generateSql` (`/analytics/sql`) is gated too. + + **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase` + is the single error exit for _every_ route the dispatcher plugin mounts — + `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, + `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against + driver dumps forever (`mapDataError`); this boundary guarded nothing, so any + driver error on any of those routes shipped its SQL to the client. Unlike ①, this + half is unconditional — it does not depend on the cube being invalid. + + The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as + `looksLikeInternalErrorLeak` (both packages already depend on it) and is now + applied at both boundaries — one predicate, one place to widen when a new + dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the + dispatcher it applies **only to 5xx**: a 4xx message is a deliberate + business/validation answer and must reach the caller intact. Sanitising costs no + diagnostics — the untouched error still reaches `errorReporter` through the + existing `__obsRecordedError` side-channel. + + **Also fixed in the same function:** `errorResponseBase` read only + `err.statusCode`, while domain errors across this codebase carry `status` (and + `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate + 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on + the analytics fallback path — was rendered as a **500**. It now reads `status` + then `statusCode`. + + **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404 + `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object; + previously the name was passed to the driver. Dashboards and KPI widgets pointed + at real objects or authored cubes are unaffected. A 5xx on a dispatcher route + whose message looks like a driver dump now reads `Internal server error` — check + server logs or your error reporter for the original. + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801) + + `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied + `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and + `resumeInternal` validated **machine state only** — the concurrent-resume latch, + the run exists, the flow exists, the suspended node still exists. Nothing asked + _who was calling_. + + Approval nodes suspend and resume through exactly that mechanism. So a resume + carrying `branchLabel: 'approve'` walked the approve edge with **no approver + check, no `sys_approval_action` row and no status mirror** — the + `sys_approval_request` row and the run then disagreed permanently. The only + thing standing between the route and the approvals rules was convention; the + showcase spelled it out in a comment ("decide via the approvals API, never a raw + engine `resume`"), and a comment in an example is not an access control. + + Removing the route was not the fix: it is load-bearing for **screen flows** — + the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node. + The gate therefore keys on **what the run is parked on**: + + - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) — + a pausing node declares who may continue it. `approval` declares `'service'`. + - The engine refuses a `'service'` suspension unless the signal carries + `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the + owning service stamps in-process — a JSON body can never produce one, so the + transport cannot forge it. `ApprovalService` stamps it on the tail of a + decision it has already authorized and recorded. + - The gate follows a **subflow** pause down to the child the signal would + actually reach, so resuming the parent is not a way around it. + - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers + **403**. Nothing is consumed — the request stays pending and the run stays + parked, so the real decision still lands. + + `screen` and `wait` pauses are unchanged, as is every path that already went + through the approvals API. What changes for consumers: + + - **FROM:** finishing an approval with + `client.automation.resume(flow, runId, { branchLabel: 'approve' })` + **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`). + The old call now answers 403 and changes nothing. + - Registering your own pausing node whose continuation belongs to a service + rather than to whoever holds the run id? Declare `resumeAuthority: 'service'` + on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that + service. + + A suspension now records the node type that produced it + (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend + time so a flow republished mid-pause cannot re-type the node out from under the + gate; rows written before this fall back to the flow definition. + +- 19e3e6e: feat(runtime)!: the standalone `default` datasource is a declaration, connected through the one datasource path (#3826) + + ADR-0062 D1 asked for exactly one "definition → live driver" path. Construction + converged earlier; the _connect + failure verdict_ half did not — the standalone + `default` driver was pre-built and smuggled into the engine as a `driver.*` + kernel service, so "what if it cannot connect" lived in `ObjectQLEngine.init()`, + a second implementation of the policy `DatasourceConnectionService` owns for + every other datasource. #3741 → #3758 showed what two copies cost: a fix to one + missed the other for three months. + + - **`createStandaloneStack` now emits a datasource DEFINITION**, not a driver. + URL→config translation and `mkdir` stay host concerns; the new + **`DefaultDatasourcePlugin`** (exported from `@objectstack/runtime`) connects + the definition at boot through the shared `DatasourceConnectionService` — + same driver factory, same failure verdict, same retained state. It must be + registered before `ObjectQLPlugin` (boot schema-sync needs the driver); + `createStandaloneStack` orders it correctly. + - **`sqlite-wasm` joined the shared driver factory** (`sqlite-wasm` / + `wasm-sqlite` ids) — it was the last bespoke construction site. + - **`bootCritical` on `ConnectableDatasource`**: the host declares a datasource + the platform cannot run without; a boot connect failure is then fatal + regardless of object bindings, sharing `OS_ALLOW_DRIVER_CONNECT_FAILURE` and + the `DEGRADED BOOT` banner with the engine-level guard. A connect policy that + denies a boot-critical datasource fails the boot loudly — the #3828 "denial is + not a failure" boundary was drawn for optional datasources. + - **`connect(record, { asDefault: true })`**: registers the built driver as the + engine's default under its natural name (no `'default'` stamping — routing to + `default` goes through the engine's default-driver fallback, and the natural + name keeps logs/lookups byte-for-byte with the previous boot). + - **`default` is a host-reserved name**: an app bundle declaring a datasource + named `default` is rejected at load (`AppPlugin`), and the runtime-admin + create rejects it too. It would shadow the host's primary datasource and, if + it passed the auto-connect gate, silently divert every unbound object. + - The primary DB now shows a REAL `status` in Setup → Datasources (#3827) — + `ok` when connected, `error` + reason when the operator boots degraded. + - `ObjectQLEngine.init()` is unchanged and keeps its fail-fast: it re-connects + the already-connected default (every open-core driver's `connect()` is + idempotent), which is exactly the boot verification #3741 wants. + - `DriverPlugin` remains the escape hatch for tests and pre-built/proxy drivers + (e.g. the CLI's `telemetry` datasource) — no longer how the standalone + default boots. The CLI serve config-load fallback (`createStorageDriver`, + incl. mysql/turso) still constructs directly; tracked in #3826. + + **Migration.** Boots through `createStandaloneStack` (CLI `serve`/`dev` + artifact path, quickstarts, embedders using the stack factory) change shape but + not behavior: same driver kinds, same URLs, same fail-fast semantics, same + escape hatch. Embedders that composed `DriverPlugin` manually are unaffected. + An app that declared a datasource literally named `default` now fails to load + with a rename instruction — that name never routed correctly to begin with. + +- 394b7a1: feat(job): honor the authored `retryPolicy` / `timeout` in the job scheduler (#3494) + + `JobSchema.retryPolicy` and `JobSchema.timeout` used to be parsed-but-ignored + (the 2026-06 liveness audit's aspirational-config cluster). They are now + enforced end to end — built rather than pruned, since retry/backoff and + per-run time limits are semantics job authors reasonably expect: + + - **spec**: `IJobService.schedule` gains an optional 4th `options` argument + (`JobScheduleOptions` with `retryPolicy` / `timeout`, mirroring the + authorable schema); new `JobRetryPolicy` type. Backward compatible — + existing 3-arg implementations and callers are unaffected. + - **service-job**: new `runWithPolicy` helper (exported, with + `JobTimeoutError`) wraps every handler invocation in `CronJobAdapter` and + `IntervalJobAdapter`; `DbJobAdapter` threads options through to its inner + adapters. Failed attempts (including timeouts) retry with exponential + backoff `backoffMs * backoffMultiplier^(retry-1)` up to `maxRetries`; + an attempt exceeding `timeout` is recorded with execution status + `'timeout'`. No `options` → exactly the legacy single-attempt behavior. + - **runtime**: declarative-jobs registration in AppPlugin forwards the + authored `retryPolicy` / `timeout` to the scheduler. + + Note: JavaScript cannot forcibly cancel an in-flight handler — a timed-out + attempt is abandoned, not killed. The retry delay caps only via the + multiplier arithmetic (no maxDelay knob yet). + + Refs #3494, #1878, #1893. + +- 8e08bc3: feat(runtime): `/ready` reports 503 when a data driver stops answering (#3756) + + `/health` returned `{status: 'ok'}` unconditionally and `/ready` only checked + whether the kernel state was `running` — a flag set once when bootstrap finishes + and never revisited. Neither probe touched the data layer. So a database that + went away _after_ boot (restart, failover, network policy change, pool exhausted, + credentials rotated) left both probes green: the load balancer kept routing to a + replica that failed 100% of its requests, and the orchestrator saw nothing wrong. + The driver's `checkHealth()` already existed and was cheap (`SELECT 1` / + `db.command({ping:1})`) but was only consumed by `datasource-admin`'s + `testConnection` — no probe path called it, and `ObjectQL` exposed no way to ask + (`drivers` is private with no accessor). + + This is the runtime-side half of #3741, which fixed only the boot-time version + of the same defect. + + - New `ObjectQL.checkDriversHealth({ timeoutMs })` pings every registered driver + and returns a `DriverHealth[]` verdict. Each probe is settled independently and + bounded (default 2s) — `checkHealth()` swallows its own errors, but on a dead + knex pool it does not return at all, waiting out `acquireConnectionTimeout` + (60s by default), and a probe that hangs is as useless as one that lies. A + driver implementing no `checkHealth()` is reported healthy: absence of a probe + is not evidence of failure. + - `GET /ready` now returns 503 with the failing driver names when the kernel is + running but a driver is down, on top of the existing booting/shutting-down + cases. The result is memoized for ~1s so Kubernetes' few-second polling does + not become one database round-trip per probe per replica. + - `GET /health` deliberately still checks nothing, and now says why in the code. + A failing _liveness_ probe restarts the pod, which cannot fix an unreachable + database but would put every replica into a restart storm for the length of the + outage. Readiness — leave the rotation — is the failure mode that helps. + + The readiness check **fails open**: a kernel with no data engine (lite kernels, + edge, metadata-only hosts), an engine predating `checkDriversHealth`, or a probe + that itself throws all read as ready, exactly as before. Readiness gates whether + a replica receives any traffic at all, so an inconclusive answer must not + black-hole a working deployment. Only a driver that positively reports itself + unhealthy takes the replica out. + + **Migration.** None. Deployments already wiring `/api/v1/ready` as their + readiness probe get the stricter check automatically; deployments that pointed a + _liveness_ probe at `/ready` should move it to `/health`, which is the endpoint + that never fails on a dependency. + +- 3216344: feat(runtime): extract the action-execution subsystem from the dispatcher — ADR-0076 D11 step ③, PR-8 (#2462) + + The 16-helper machinery behind server-registered business actions + (declaration collection/resolution, ADR-0104 param enforcement, the + permission/AI-exposure gates, the engine facade + session shape, invocation, + and the `callData` protocol/ObjectQL bridge — ~560 lines) moves to + `action-execution.ts`, depending only on the narrow `ActionExecutionDeps` + slice (resolveService + getObjectQL; NO env-resolution state). The + ADR-0104 warn-once statics ride along as module functions. The dispatcher + keeps four thin delegates with in-class callers; twelve internal-only + helpers are called directly on the module. This is the pre-cut that turns + the `/actions` and `/mcp` domain extractions into mechanical moves (PR-9). + Zero behavior change — runtime 649, http-conformance 41, dogfood 351 green. + +- f5bfac8: feat(runtime): extract the /actions and /mcp dispatcher domain bodies — ADR-0076 D11 step ③, PR-9 (#2462) + + The two deep-coupled domains ride the PR-8 action-execution subsystem out + of the dispatcher: `domains/actions.ts` (ADR-0066 D4 permission gate + + ADR-0104 param contract) and `domains/mcp.ts` (JSON-RPC transport, + `/mcp/skill` download, OAuth resource-metadata, the principal-bound tool + bridge). Env-resolution state stays behind two new deps seams — + `getDefaultEnvironmentId` and `resolveProjectKernelObjectQL` (the ADR-0006 + direct-caller kernel swap, side effect dispatcher-owned). The legacy + `/mcp/skill`-before-`/mcp` precedence is reproduced with ordered registry + entries incl. the `?` forms; the actions redundant trailing-slash regex + (the CodeQL polynomial-redos twin) is dropped for split+filter. The authz + identity pin for `buildMcpBridge(context)` follows the body to + `domains/mcp.ts`. Zero behavior change — runtime 649, http-conformance 41, + dogfood 351 green. + +- 6163393: feat(runtime): extract the /auth and /ai dispatcher domain bodies — ADR-0076 D11 step ③, PR-7 (#2462) + + `/auth` (better-auth service bridge + the browser-safe mock fallback for + MSW/test environments, with the local `randomUUID` wrapper moving alongside + its only consumer) and `/ai` (dispatch to the AI plugin's kernel-cached + route table with per-route auth-contract enforcement and actor threading) + move to `domains/`. `DomainHandlerDeps` grows two lazily-read members: + `isAuthRequired()` (the deployment's requireAuth posture — + construction-order safe) and `getRegisteredAiRoutes()`. `/mcp` was + deliberately excluded: `buildMcpBridge` couples to the action-execution + family (callData / actionPermissionError / invokeBusinessAction), so it + goes with the /actions /meta /data deep-coupling batch. Zero behavior + change — http-conformance (41) plus 5 new seam tests. + +- 688e9df: feat(runtime): extract the /automation dispatcher domain body — ADR-0076 D11 step ③, PR-6 (#2462) + + The automation bridge (flow CRUD, trigger/execute, runs history, + pause/resume — the ADR-0018/0019/0022 surfaces, ~260 lines) moves to + `domains/automation.ts` with zero new deps-contract growth. The route-order + subtlety is preserved verbatim: `/actions`, `/connectors` and `/_status` + keep their guard positions before the `/:name → getFlow` catch-all. Zero + behavior change — http-conformance (41) plus 3 new seam tests. + +- 8f124a7: feat(runtime): extract the first four dispatcher domain bodies into `domains/` modules — ADR-0076 D11 step ③, PR-2 (#2462) + + The `/analytics`, `/i18n`, `/notifications` and `/security` handler bodies + move out of the `HttpDispatcher` god class into per-domain modules under + `packages/runtime/src/domains/`, running against an explicit + `DomainHandlerDeps` contract (resolveService / getService / success / error — + the WHOLE dispatcher surface a domain may touch). The dispatcher keeps thin + `handleXxx` delegates for direct callers, and `/notifications` + `/security` + leave the legacy if-chain for the domain registry (new `match: 'segment'` + preserves their `=== p || startsWith(p + '/')` branch shape exactly). + + Route registration stays dispatcher-owned on purpose: most service slots are + multi-provider (i18n = I18nServicePlugin OR the AppPlugin in-memory fallback; + analytics = service-analytics OR the ObjectQLPlugin fallback), so a route is + the bridge to a SLOT, not the property of any one providing package. Zero + behavior change — http-conformance (41 cross-adapter assertions) and the + seam suite (18 tests) lock it. + +- 21ca1d5: feat(runtime): extract /keys, /storage and /ui dispatcher domain bodies — ADR-0076 D11 step ③, PR-3 (#2462) + + Continues the per-domain decomposition: three more handler bodies move out + of `HttpDispatcher` into `domains/keys.ts` (incl. the zero-tolerance + API-key-mint security contract), `domains/storage.ts` and `domains/ui.ts`, + running on the explicit `DomainHandlerDeps` contract (extended with + `getObjectQL` for the data-plane domains). The `/keys` legacy branch's + `'/keys?'` query-string form is reproduced with a second registry entry; + storage drops its strictly-redundant `kernel.services` index-access fallback + (dead under Map-shaped services, duplicate under object-shaped ones). Thin + `handleXxx` delegates remain for direct callers. Zero behavior change — + locked by the 41-assertion http-conformance suite and 6 new seam tests. + +- 03b11e8: feat(runtime): thin domain-handler registry seam in the HTTP dispatcher — ADR-0076 D11 step ③, PR-1 (#2462) + + `dispatch()` routed every domain through one hand-written + `if (cleanPath.startsWith('/xxx'))` chain — the "god implementation on a clean + port" shape ADR-0076 D11 calls out. This lands the decomposition seam: a + first-match `DomainHandlerRegistry` consulted before the legacy chain, plus a + public `HttpDispatcher.registerDomainHandler()` so follow-up PRs can hand each + domain's normalized handler to its owning service package. + + Migration discipline is "registry first, code moves later, ownership last": + this PR only wraps four existing branches (`/health`, `/ready`, `/analytics`, + `/i18n` — three shapes: no-service probe, service bridge, optional-service 501) into registry entries with faithful legacy matching semantics. Zero + behavior change, locked by the 41-assertion http-conformance cross-adapter + suite and 11 new seam tests. + +- 8891f93: feat(runtime): extract the /meta and /data dispatcher domain bodies — ADR-0076 D11 step ③, PR-10, the terminal cut (#2462) + + The last two domains leave the dispatcher: `domains/meta.ts` (metadata + read/write incl. ADR-0033 draft-aware protocol paths, ADR-0046 doc slimming + riding along with its exclusive `slimDocList` helper) and `domains/data.ts` + (CRUD/query over the action-execution `callData` bridge; the multi-tenant + unresolved-environment 428 now keys off a semantic `isMultiTenantHost()` + deps member instead of poking `kernelResolver`). **The dispatch() if-chain + is now EMPTY of domains** — 18 domains resolve through the registry, and + `createHonoApp`'s catch-all is ready for retirement (step ① of #2462). + Zero behavior change — runtime 649, http-conformance 41, dogfood 351 green. + +- d729a31: feat(runtime): extract the /packages dispatcher domain body — ADR-0076 D11 step ③, PR-5 (#2462) + + The largest domain so far (~680 lines: the handler plus its two exclusive + helpers `assemblePackageManifest` and `applyPublishedSeeds`) moves to + `domains/packages.ts` — list/install/enable/disable, ADR-0033 draft + publish/discard, ADR-0067 commit history & rollback, ADR-0070 export / + orphan adoption / duplicate, delete. `DomainHandlerDeps` grows the shared + facilities the body needs: `errorFromThrown` (field-anchored 422s), + `resolveActiveOrganizationId` (session org), `announceKernelEvent` + (`metadata:reloaded` after publish), and an optional `logger`. The step-② + (#3142) single-pipeline behavior is preserved. Zero behavior change — + http-conformance (41) plus 4 new seam tests (incl. the 409 + duplicate-install guard). + +- cb8322e: feat(runtime): extract the /share-links dispatcher domain body — ADR-0076 D11 step ③, PR-4 (#2462) + + The share-link capability-token surface (ADR-0047) moves out of + `HttpDispatcher` into `domains/share-links.ts`. This is cloud's designed + primary surface for per-env kernels (`registerShareLinkRoutes: false`, host + dispatcher serves after kernel swap — the #2462 step-① re-scope finding), so + the handler keeps working from the registry exactly as from the if-chain. + `DomainHandlerDeps` grows `getRequestKernelService` (reads off the + per-request RESOLVED kernel — the engine the shareLinks service is bound to) + and `routeNotFound` (the shared 404 envelope). Zero behavior change — locked + by http-conformance (41) and 5 new seam tests incl. token-resolve redaction. + +### Patch Changes + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 6877e9a: test(client,runtime): the last wildcard was wrong evidence, not weak — AI ratchet 3 → 0 (#3718) + + The capstone (#3642) ratcheted "matched only by a `**` family" as weaker + evidence, to be driven down by enumerating each dynamic family. 60 → 3 after + #3656. The last 3 were `ai.nlq` / `ai.suggest` / `ai.insights` on `* /ai/**`. + + Enumerating that family (in `cloud`, where `service-ai` lives) showed the + wildcard had not been weak evidence but **wrong** evidence. `buildAIRoutes()` + mounts 12 routes — `chat`, `chat/stream`, `complete`, `models`, `status`, + `effective-model`, six `conversations` — and **none** is `/nlq`, `/suggest` or + `/insights`. The SDK's entire AI namespace is dead, the entire real AI surface + is unexpressed by the SDK, and the two sets are disjoint (#3718). + + The old row's note even claimed the client "expresses nlq/suggest/insights + against the REST AI routes". That was never verified and is false: + `DEFAULT_AI_ROUTES` declares them but has no runtime consumer (only the spec's + own test reads it), and `aiNlq?`/`aiSuggest?`/`aiInsights?` are optional + protocol methods nothing implements. + + `/api/v1/ai/` becomes a bounded prefix exemption alongside the control plane — + two cross-repo surfaces, both ledgered in `cloud` — and the wildcard-only + assertion becomes `toBe(0)`, not a ratchet: every matched call now rests on an + exact enumerated route. Mutation-checked in both directions (removing the + exemption re-exposes exactly the 3, and the pre-change count was verified to be + exactly those 3 and nothing else). + + Test-and-comment changes only; no runtime behaviour is affected. + +- 0bab8bb: fix(client,runtime): analytics.meta/explain now call routes that actually exist (#3584) + + The route audit (#3563) ledgered four dispatcher↔client shape mismatches. + Re-verification showed the two analytics shapes the client spoke — + `GET /analytics/meta/:cube` and `POST /analytics/explain` — were served by + **nothing**: not the dispatcher, not `@objectstack/rest`, not + `service-analytics`. Both methods 404ed against every deployment. + + - `analytics.meta(cube?)` — FROM `GET /analytics/meta/:cube` TO + `GET /analytics/meta[?cube=]`. The cube argument is now optional; when + given, the dispatcher threads it into `AnalyticsService.getMeta(cubeName?)`, + which always supported the filter. Responses now use the dispatcher envelope + (`{ success, data }`). + - `analytics.explain(payload)` — FROM `POST /analytics/explain` TO + `POST /analytics/sql` (the dispatcher's SQL dry-run route, backed by + `generateSql`). Method name unchanged. + + No migration is expected in practice: a method that unconditionally 404ed can + have no working callers (none exist in objectstack or objectui). Anyone who + had hand-rolled fetches against the imaginary shapes should switch to the + routes above. + + The two storage rows from the same audit are deliberately NOT reshaped: the + presigned/chunked protocol the SDK speaks is registered autonomously by + `service-storage` on any http-server and stays canonical; the dispatcher's + bare `POST /storage/upload` / `GET /storage/file/:id` are reclassified in the + route ledger as a `server-only` low-level compat surface. + +- 3c8cfd1: fix(rest): make the API-exposure gate's metadata fail-open observable (#3545, #3391 follow-up) + + The object API-exposure gate (`apiEnabled` / `apiMethods`) fails OPEN when object + metadata can't be resolved, so a transient metadata outage doesn't 405 every + request. #3545 evaluated the residual risk of that path and confirmed it is + acceptable — the gate is a **surface-area control, not the authorization + boundary**: every request still passes auth and the ObjectQL security middleware + (CRUD / FLS / RLS) on the data call regardless of the gate's outcome, so a + fail-open can never bypass data authorization. + + The one gap was that the fail-open was **silent** — a persistent metadata fault + (store down / corrupt schema doc), during which the gate allows every operation + unchecked, looked identical to healthy operation. + + - **rest** `loadObjectItems` now LOGS a _thrown_ metadata read (a real fault) + while leaving a legitimately-empty registry (a cold-start `[]`) silent — so a + genuine outage is diagnosable without false alarms during normal startup. The + behavior is unchanged (still returns `[]` → gate abstains → data path + security + enforce). + - **runtime** `api-exposure.ts` records the #3545 tiered decision in its + contract doc: keep fail-open when the whole metadata service is unavailable + (failing closed would break the cold-start window for no security gain); the + narrow "object resolvable but its `enable` policy is present-yet-unreadable" + widen (unreachable through Zod-validated registration) is deferred to the + exposure-semantics window (#3543). + + No contract or behavior change to the gate itself — observability + decision + record only. + +- d3f2ff6: feat(client): `actions` surface — the SDK path to server-registered actions (#3563 PR-2) + + `client.actions.invoke(object, action, { recordId, params })` and + `client.actions.invokeGlobal(action, opts)` dispatch handlers registered via + `engine.registerAction` (`POST /api/v1/actions/...`). This closes the largest + gap in the #3563 route audit: the whole `/actions` domain — the documented way + to expose custom server-side operations — was unreachable from the SDK, and + every console hand-rolled `fetch` for it. The record id travels in the body, + which both server URL shapes honor; the handler's own business failure comes + back as `{ success: false, error }` rather than a thrown exception. + + The route ledger flips all three `/actions` rows to `sdk` and the gap ratchet + drops 27 → 24. Also takes the documentation-drift findings from the audit: + the client README no longer documents six methods that do not exist, + `CLIENT_SPEC_COMPLIANCE.md` is retired to a tombstone pointing at the + CI-enforced ledger (its "FULLY COMPLIANT" verdict was measured against a + route table nothing consumes), and the docs-site SDK page documents the new + surface. + +- b7550d6: feat(client): `keys`, `shareLinks`, and `security` surfaces (#3563 PR-3) + + Three more domains the route audit found with zero SDK expression: + + - `client.keys.create({ name?, expiresAt? })` — mints a `sys_api_key` + (`POST /api/v1/keys`). The raw secret comes back exactly once; `user_id` + is pinned server-side. There was previously no SDK path to create an API + key at all. + - `client.shareLinks.create / list / revoke` — authenticated management of + record share links. Listing is server-constrained to the caller's own + links; the public token-consumption routes stay browser-only by design. + - `client.security.suggestedBindings.list / confirm / dismiss` — the + ADR-0090 admin surface for package audience-binding suggestions. + + The route ledger flips all seven rows to `sdk` and the gap ratchet drops + 24 → 17. + +- 0164f40: feat(client): the final six route-audit gaps — meta drafts/published/FSM + automation descriptors (#3563 PR-5) + + - `meta.getPublished(type, name)` — the published version of a metadata item + (ADR-0033; compound names pass through unencoded, matching `getItem`). + - `meta.listDrafts({ packageId?, type? })` — pending drafts the active-only + lists hide. + - `meta.getLegalNextStates(object, field, from?)` — ADR-0020 FSM + introspection ("from here, where can this record go?"). + - `automation.listActions({ paradigm?, source?, category? })` / + `automation.listConnectors({ type? })` — the ADR-0018/0022 descriptor + registries backing the Studio designer's pickers. + - `automation.getRuntimeStatus()` — per-flow enabled/bound engine state. + + With these, the #3563 gap ratchet reaches **0** (from 27): every dispatcher + route that should be SDK-expressible is, and the conformance guard keeps it + that way. + +- e295ad1: feat(client): the eleven package-lifecycle methods (#3563 PR-4) + + `client.packages` grows from install/enable to the full lifecycle the server + has shipped for three ADR generations: `update` (manifest edit), + `publish`, `publishDrafts` / `discardDrafts` (ADR-0033 whole-app draft + promotion), `listCommits` / `revertCommit` / `rollback` (ADR-0067 commit + timeline), `revert`, `export`, `adoptOrphans`, `duplicate` (ADR-0070 + portability). All eleven routes existed with no SDK expression — Studio + reached them via raw fetch. + + The route ledger flips all eleven rows to `sdk` and the gap ratchet drops + 17 → 6 (from 27 at the start of the audit). + +- 48c110e: feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) + + #3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two + gaps survived that fix, both in the cases that still boot — a policy denial, an + `autoConnect` datasource, or any failure the operator waved through with + `OS_ALLOW_DRIVER_CONNECT_FAILURE`: + + - **It was invisible.** `DatasourceSummary.status` was the literal `'unvalidated'` + for every row — the contract declared three states and the implementation only + ever emitted one — so a dead datasource looked exactly like a healthy-untested + one. `checkDriversHealth()` could not help either: it iterates registered + drivers, and a datasource that never connected was never registered, so it is + _absent_ from the probe rather than unhealthy. The only trace was a warning + that scrolled past at boot, which made the diagnostic procedure "restart the + server and re-read the logs". + - **The query-time error said nothing.** `getDriver()` answered four different + situations with one sentence, `Datasource 'x' is not registered.`: refused by + policy, failed to connect under the escape hatch, a misspelled name, and + `active: false`. Only the third is an authoring bug, so the other three sent + the reader hunting for a typo that does not exist. + + Both come from the same root: `connect()` already produced a `ConnectResult` for + every attempt and every caller threw it away. + + - **`DatasourceConnectionService` retains the last verdict per datasource**, with a + coarse `availability` (`available` / `blocked` / `failed` / `unattempted`) beside + the raw status. New `getConnectionState(name)` / `listConnectionStates()`. + `disconnect()` drops it, so a removed pool stops explaining itself. + - **`DatasourceSummary.status` tells the truth**: `ok` | `error` | `blocked` | + `unvalidated`, with a new operator-facing `statusReason`. `blocked` is new and + deliberate — a policy denial is a decision, not a fault, and will not clear on + its own. Reported in **Setup → Datasources**, `GET /api/v1/datasources`, and the + summary returned from create/update, so a "Save" whose pool failed to open is no + longer presented as success. + - **`ERR_DATASOURCE_UNAVAILABLE` (HTTP 503)**: new `DatasourceUnavailableError` + from `@objectstack/objectql`, thrown by `getDriver()` when the connection layer + recorded _why_ a declared datasource has no driver. An undeclared name keeps the + original message — there is genuinely nothing to add. 503 rather than 500/400: + nothing about the request is wrong, and the state may clear. + - **A privileged/public split for the reason.** The error **never** carries the + underlying cause — connect failures routinely contain hosts, ports and DSNs, and + a policy's `reason` is written for operators. Those stay in the logs and the + (admin-gated) datasource list. `DatasourceConnectDecision` gains an opt-in + `publicReason` for hosts that want to tell tenants something specific + (e.g. `'External datasources require the Scale plan.'`); it is the only string + that reaches an end user. + - **Readiness is deliberately not gated on this.** `/ready` still reflects + registered-driver health only: an optional datasource being down must not pull an + otherwise-working replica out of the load balancer. + + Also lands a drift guard for **#3826**, and corrects ADR-0062's status while doing + it. The ADR claimed D1 ("exactly one definition → live driver path") as + implemented; only the _construction_ half converged. The `default` driver is still + registered as a `driver.*` kernel service and connected by `ObjectQLEngine.init()`, + with its own failure verdict, pool teardown, and no connect policy. What blocks the + merge is an input-shape mismatch, not ordering: `connect()` takes a datasource + _definition_ and builds the driver, while `default` arrives pre-built, and routing + it through the service would make `ObjectQLPlugin`'s boot depend on an optional + higher-layer service. Until that is designed, `degraded-boot-parity.test.ts` pins + both paths to the same operator-visible contract (fail-fast by default, identical + `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr) so a change + to one that forgets the other fails CI — #3741 → #3758 was exactly that miss, and + it cost three months and a second bug report. + + **Migration.** Additive. `DatasourceSummary.status` gains a `'blocked'` member: a + consumer exhaustively switching on it needs a case (the admin UI shows it as a + distinct state). Nothing that was `'ok'` or `'error'` changes meaning; rows that + were reported `'unvalidated'` now report their real state. Query-time errors for a + datasource the connection layer recorded change from a generic `Error` to + `DatasourceUnavailableError` (503 instead of the previous catch-all status); + matching on the old `is not registered` text still works for the undeclared-name + case, which is the only one that was ever accurate. + +- cbedd62: fix(runtime,hono): close the remaining raw-driver-message exits on the HTTP boundary (#3867 follow-up) + + #3867 sanitised `dispatcher-plugin`'s `errorResponseBase`. That covers errors + **thrown** out of `dispatch()` — but not the ones it **returns**. A + `{handled: true, response}` result goes to `sendResult`, never through that + catch, and those bodies are built by `HttpDispatcher.error()`, which passed the + message through verbatim. Sweeping the boundary for the same defect class (the + follow-up #3867 called for) turned up two more live exits: + + **`HttpDispatcher.error()`** — the single construction point for every returned + error response. Reachable with a raw driver message today through + `errorFromThrown` (`/meta` save, `/packages` install) and the MCP transport's + `deps.error(err?.message, 500)`. Pinned by a test that drives + `PUT /meta/:type/:name` with a throwing `protocol.saveMetaItem`: without the + guard the response body is the driver's `insert into \`sys_team\` … UNIQUE + constraint failed: sys_team.id`, naming a physical table and column. + + **`@objectstack/hono`'s auth-config route** — a 500 built from a caught + error with `message: err.message`. The auth service reads from the database, so + that message can carry a driver dump. + + Both apply the same `looksLikeInternalErrorLeak` predicate #3867 put in + `@objectstack/types`, and both are scoped to **5xx** for the same reason: a 4xx + message is a deliberate business/validation answer (`Path must be +/actions/:object/:action`, a hook's own `throw`, a `saveMetaItem` field error) + and must reach the caller intact. Structured `details` — the semantic `code` and + per-field `issues` the Studio maps back to inputs — is never touched, so a + sanitised 500 still carries everything a client can act on. + + Diagnostics are unaffected: callers that threw still hand the original error to + `errorReporter` via `__obsRecordedError`, and every 5xx is logged server-side. + + Audited in the same pass and deliberately left alone: the inline error bodies in + the `ai` / `mcp` domains (static literal strings, no interpolated error text) and + `plugin-hono-server`'s 403s (4xx, deliberate messages). With this change every + dynamic message on both dispatcher exits and the REST data routes goes through + one predicate. + +- 1d4756e: fix(i18n)!: `/i18n/labels/:object/:locale` emits the entry shape it declares — + and stops discarding `help`/`options` (#3847) + + `GetFieldLabelsResponseSchema` has always declared each label as an object: + + ```ts + labels: z.record( + z.string(), + z.object({ + label: z.string(), + help: z.string().optional(), + options: z.record(z.string(), z.string()).optional(), + }) + ); + ``` + + Both serving surfaces emitted `Record` — a bare label per field. + A client typed against `GetFieldLabelsResponse` read `labels[field].label` and + got `undefined`, because the value was the string itself. The SDK's type was + right the whole time; the servers were wrong. + + The cost is not only the type mismatch. `FieldTranslationSchema` carries `help` + and `options`, bundles populate them, and the endpoint threw them away. objectui + needs exactly those — its `spec-translations.ts` transform reads `label` **and** + `options` (as `fieldOptions...`) — and gets them by pulling the + whole bundle from `/i18n/translations/:locale` and resolving client-side. The + per-object endpoint could not have served it even if it wanted to: the data was + being dropped at the emit site. + + Fixed at that emit site, `resolveObjectFieldLabels`, which both surfaces already + share as of #3833 — so one change covers both. `help` and `options` are attached + only when non-empty: an `options: {}` would claim a field has translated options + and hand back none, and a `help: ''` would erase a caller's source help text. + Fields with no non-empty `label` are still omitted entirely, which is what lets + `ResolvedFieldLabel.label` be a required string. + + **The response schema is unchanged** — this moves the implementation onto the + contract, not the contract onto the implementation. Generated docs are + byte-identical for that reason. + + `placeholder` is deliberately left out. `FieldTranslationSchema` has it and the + response schema does not, so emitting it would be widening the contract rather + than satisfying it — and adding an optional response field later is additive and + non-breaking, whereas guessing now is not. + + The regression guard is the part worth keeping: a test that builds the response + body from the shared helper and parses it with `GetFieldLabelsResponseSchema`. + Nothing had ever put the emitted value and the declared contract in one + assertion, which is precisely why a bare string could sit under an object schema + unnoticed. Third and last of the declared ≠ enforced gaps on this endpoint + family, after #3676 (request filters no server read) and #3833 (a derivation + scanning a retired dialect). + + BREAKING: `labels[field]` is now `{ label, help?, options? }` rather than a + string. No consumer in this repo or objectui read it — objectui never calls this + route, and in-repo use is the SDK method plus URL-shape tests — so the practical + blast radius is nil, and this is the cheap moment to align it. + +- 720c5ad: fix(runtime,i18n): the dispatcher's field-labels route reads the bundle shape + producers actually write — one shared derivation (#3833) + + `GET /i18n/labels/:object/:locale` served through the dispatcher returned + `{ labels: {} }` for every provider. Its derivation scanned for flat + `o..fields.` keys: + + ```ts + const prefix = `o.${objectName}.fields.`; + for (const [key, value] of Object.entries(translations)) { … } + ``` + + That dialect was retired by #3778 — no producer has ever written it, and a real + bundle's top-level keys are the `TranslationData` groups (`objects`, `apps`, + `messages`, …), so the prefix could not match anything. 4cca74c fixed the + identical derivation in `service-i18n` and did not reach the dispatcher's copy. + + This is not a rare fallback. `getFieldLabels` is optional on `II18nService` and + **nothing implements it** — not `memory-i18n`, not `file-i18n-adapter` — so the + dedicated-method branch both surfaces check first is dead in production and this + derivation is the only path there is. Any stack served by the dispatcher (the + AppPlugin in-memory provider auto-registered for stacks declaring translation + bundles) got an empty map, indistinguishable from "this object has no translated + labels": nothing errored, nothing warned. + + Worse than the class it was found next to. #3676, which prompted the check, + ignored a declared filter and returned the full bundle — a correct superset. This + returned nothing and said it was fine. + + The derivation now lives once, as `resolveObjectFieldLabels` in + `packages/spec/src/system/i18n-resolver.ts`, alongside the other resolvers that + read `TranslationData`. Both surfaces call it. Keeping a copy each is precisely + how one got fixed and the other did not; the next bundle-shape change now has one + place to land. Fields carrying no non-empty `label` stay omitted rather than + emitted blank — partial translation is the normal state, and callers merge this + map over their source labels, where a `''` would erase them. + + ### The tests were fiction on both sides + + The dispatcher's fallback test fed flat `o.contact.fields.first_name` keys and + asserted labels came back, so it passed on data that cannot occur while + production returned `{}` — the same failure mode as the client test retired in + #3676, which asserted a query string was built that no server read. It now feeds + the nested shape, and was confirmed to fail against the pre-fix code (`expected +{} to deeply equal { first_name: 'First Name', … }`) rather than merely passing + after it. The shared helper carries its own unit tests, including one pinning + that the retired flat dialect resolves to `{}`. + + The same suite's mock also declared a `getFieldLabels` no shipped provider has, + and returned flat-dialect data from `getTranslations`; both now reflect what a + real provider does, with the divergence noted where it remains deliberate. + + Not addressed here, filed separately: `GetFieldLabelsResponseSchema` declares + `labels` as `Record`, but both surfaces emit + `Record` — a third declared ≠ enforced gap in the same endpoint, + and a wire-shape change too breaking to fold into a correctness fix. + +- 41642b0: fix(runtime,i18n)!: `/i18n/locales` answers in one shape — plus the + success-envelope conformance gate that found it + + Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not + match the schema declaring it, and each survived a green suite because **every + test asserted the emitted body against a hand-written literal**. Comparing + output to a literal proves the code does what the test author believed; it + cannot prove the code does what the contract declares. Nothing had ever put the + emitted value and the declared schema in the same assertion. + + This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts` + in `runtime`, the missing success-path twin of service-i18n's + `error-envelope.conformance.test.ts` and the same pairing storage got in #3689. + Every `/i18n` success body is parsed against `BaseResponseSchema` and against + the schema `plugin-rest-api` names for that route (`responseSchema: +'GetLocalesResponseSchema'`, …), imported rather than restated. + + **It found a fourth gap on its first run.** `GET /i18n/locales` passed + `getLocales()`'s raw `string[]` straight through the dispatcher, while + `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and + service-i18n, the _other_ provider of this identical route, already emitted + descriptors. One endpoint, two shapes, decided by which plugin mounted it, with + the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type. + + That is the same split #3833 found in the field-labels derivation, one route + over, and it happened for the same reason: two surfaces, one mapping, kept + twice. So the mapping is now shared as `toLocaleDescriptors` in + `packages/spec/src/system/i18n-resolver.ts`, next to `resolveObjectFieldLabels`, + and both surfaces call it. `label` is the locale code — no display-name source + exists in the tree and the schema requires the field; inventing an ICU + display-name table here would be a product decision, not an implementation + detail. + + The gate was verified the same way #3833's was: the fix was reverted and the + suite confirmed to fail on it — + + ``` + locales body does not match its declared schema: + [{"expected":"object","code":"invalid_type","path":["locales",0], + "message":"Invalid input: expected object, received string"}, …] + ``` + + — rather than merely passing once written. Five existing tests pinned the bare + `string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned + while the shape is owned by the schema. + + BREAKING: `GET /i18n/locales` served by the dispatcher now returns + `[{ code, label, isDefault }]` instead of `['en', …]`. Callers on the + service-i18n mount already received this shape, and the SDK's published + `GetLocalesResponse` type has always described it, so this ends a divergence + rather than starting one. + + Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a + `responseSchema` name on essentially every route (29 declarations across 28 + handlers), so the route → declaring-schema mapping needed to run this check + repo-wide exists today and is unused. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- 7180ed5: fix(security): fail closed when an object's security posture can't be resolved + (#3545) + + #3545 accepted the API-exposure gate's fail-open on unresolvable metadata on one + load-bearing premise: that gate is a SURFACE-AREA control, while the real + authorization boundary — auth + the ObjectQL security middleware (CRUD/FLS/RLS) + — enforces unconditionally on the data call whatever the gate answers. + + Verifying that premise rather than assuming it shows it did not hold. The + middleware does run unconditionally, but two of its INPUTS were read from the + same object metadata and defaulted permissively when it could not be resolved, + so the very trigger the issue is about reached one layer PAST the gate, into the + boundary itself: an unresolved `access.default` read as PUBLIC (so a plain `'*'` + wildcard covered an object ADR-0066 D2 excludes from it) and an unresolved + `requiredPermissions` read as NO CONTRACT (so the D3 capability AND-gate was + skipped entirely). + + `getObjectSecurityMeta` now flags `unresolved`, and the three consumers that turn + posture into an access decision fail closed on it: the middleware denies (with an + error log, so a persistent metadata outage is observable rather than a silent + blanket-allow), `canExport` denies, and `getReadableFields` exposes no columns — + the same stance already taken for a permission-resolution failure and a dangling + delegator. `computeLayeredRlsFilter` keeps consuming the defaults deliberately: + there the permissive value WITHHOLDS the cross-tenant exemption, so it is already + the closed direction. + + Blast radius is bounded to the risky case. System/boot writes (`isSystem`) and + principal-less/anonymous contexts short-circuit earlier in the middleware, so + reaching the new check means an authenticated principal with resolved grants + asking for an object whose declaration is missing; the cold-start window is + served by those short-circuits, not by the permissive default. The exposure + gate's own tiered decision (transient unavailability → fail open) is therefore + unchanged — it now rests on a boundary that actually holds. + + The explain engine reports the denial on its existing `object_crud` layer naming + the real cause, so the "why am I denied?" surface cannot drift from enforcement. + +- 083c414: fix(runtime): replace the polynomial-redos trailing-slash regex in the notifications domain with split+filter (CodeQL high, surfaced by #3507) + + The legacy `path.replace(/\/+$/, '')` in the notifications handler had + carried a polynomial-backtracking regex over request-controlled input since + ADR-0030; the domain extraction (#3507) made the line "changed code" and + CodeQL flagged it. Same split+filter treatment the security domain already + uses for the identical pattern. Redundant slashes in the sub-path now + collapse (`//read//` → `read`), matching the security domain's semantics. + +- 3d5f726: feat(rest): route audit tranche 2 — the REST surface gets its own ledger + + conformance guard (#3587, follow-up to #3563) + + The dispatcher tranche closed its 27 gaps and guards them (#3569…#3579), but + `@objectstack/rest` mounts a second, larger surface the client also reaches — + 89 routes, never audited. `rest-route-ledger.ts` now records a reviewed + disposition for every one of them (38 sdk, 43 gap, 3 server-only, 3 public, + 2 mismatch), and the guard is real enumeration on both sources: RouteManager + routes via the `getRoutes()` introspection seam, and the two + RouteManager-bypassing registrars (`package-routes.ts`, + `external-datasource-routes.ts`) via captured mock-server registrations — no + pinned-by-hand list. The client half + (`rest-route-ledger-coverage.test.ts`) verifies every claimed method exists; + a 43-gap ratchet is wired into CI. Every guard direction was negative-tested. + + Notable dispositions the audit surfaced: `POST /api/v1/packages` is a + publish/install shape collision between REST and the dispatcher (REST + registers first and wins) — ledgered `mismatch`; the REST + `GET /ui/view/:object/:type` path dialect is unreachable by the SDK's + query-param dialect — ledgered `mismatch`; `service-storage` / + `service-i18n` mount a third route surface outside `@objectstack/rest`, + explicitly out of scope here and tracked under #3587. + + No behavior change — data + tests only, plus a scope-note refresh in the + runtime ledger pointing at the new REST ledger. + +- 70a1ce1: fix(automation): the resume gate follows `map:` too, and the route stops accepting engine-internal variables (#3853) + + Two holes in the #3801 resume gate, both demonstrated with a repro. + + **1. The chain walk missed `map:`.** `resumeInternal` handles the two linked-run + correlations oppositely — a `subflow:` pause _delegates_ the signal to the child, + a `map:` pause _re-runs_ the map node — and the gate followed only the first. So + a run parked on a `map` node was judged on `map` itself (`resumeAuthority: 'any'`) + and let through even while the item it was waiting on sat on an `approval`. + + `map` is the batch-approval shape, and the map parent's run id is the one a + launcher holds. Since `$mapState.started` is advanced past the in-flight item + before the suspend, an empty-body resume of the parent **skipped that item's + approval outright**, orphaning its still-pending request; a later real decision + then bubbled into a parent already waiting on the next item, cascading the + misalignment. + + The walk now follows both prefixes: a linked-run pause is waiting on a CHILD, so + the child's node carries the authority — the gate reads _the item, not the loop_. + + **2. Resume `inputs` could write the engine's `$` namespace.** They are applied + as bare flow variables, so a caller could set the exact handoff keys the engine's + map bubble uses (`.$mapItemDone` / `$mapItemOutput`) and have the map + record a per-item result for a decision nobody made — the node id is readable + from `GET /automation/:name`. The same reached `$runId`, which `approval` / + `wait` nodes use to correlate external state back to a run. + + `POST /automation/:name/runs/:runId/resume` now answers **400** when `inputs` + names anything in the engine namespace (`$…`, or a `.$` segment). Enforced at the + transport, not in the engine, so the in-process bubble keeps working — the same + trust split the gate itself uses. + + Nothing changes for author-declared variables: `{ new_assignee: 'ada' }` and + dotted names like `collect.note` are unaffected. If you were driving a batch- + approval `map` by resuming the map's own run id, resume the **item's** run + through its owning service instead (e.g. `client.approvals.approve`) — the map + advances itself when the item completes. + +- 93f267f: fix(automation): one chokepoint for the resume signal — `output` reopened the hole `inputs` had just closed (#3879) + + #3853 guarded `signal.variables` at the route. That closed one of **two** + equivalent paths into the same variable map and left the other open: + `signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run + parked on a `map` node `run.nodeId` **is** the map node — so + + ```jsonc + { + "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } + } + ``` + + writes exactly the `.$mapItemDone` the `inputs` guard had refused, + making the map record a result for an item nobody decided. Demonstrated with a + repro, then fixed. + + Scope: the #3853 map gate still held, so a batch whose pending item sits on an + `approval` was refused before any of this — the **approval bypass stayed + closed**. The residual was forging the recorded result of an item on an + _ungated_ pause. + + Two escapes with one shape is a design signal, not two bugs, so the fix is + structural rather than a third patch: + + - **`applyResumeSignal` is the one place a resume signal reaches the variable + map.** Both fields are collected into a single write list (already in final, + prefixed form), checked, then applied — a new signal field is covered by + construction rather than by remembering. + - **All-or-nothing**, and checked _before_ the suspension is consumed: a + rejected signal applies nothing (not even legitimate keys sent alongside) and + the run stays parked, so the real continuation still lands. + - **The engine owns the rule; the transport maps the verdict.** `resume` returns + `{ success: false, code: 'invalid_signal' }`; the route answers **400**. The + SDK and any future adapter inherit it — implemented in one transport it + protected exactly one transport, and one field of it. + - Engine-built signals (the subflow output mapping, the map item handoff) are + exempt via a module-private symbol. Deliberately _not_ + `RESUME_AUTHORITY_SERVICE`: that marker means "the owning service authorized + this decision", and a service still has no business writing engine internals. + + `AutomationResult.code` gains `'invalid_signal'` alongside `'forbidden'` — a + `switch` over it needs a new arm; a plain read does not. + + Nothing changes for authoring: ordinary variables pass, `$` mid-name (`price$`) + and dotted names (`collect.note`) included. Only names the engine reserves — + `$…` or a `.$` segment — are refused. + +- 48d5a1c: Route ledger + conformance guard for the dispatcher↔client surface (#3563) + + #3528's root-cause class — a route that exists and works while + `@objectstack/client` has no way to express it — now has an inventory and a + ratchet. `route-ledger.ts` records the audited disposition of every dispatcher + route (sdk / gap / server-only / public / dynamic / mismatch); + The guard is split along the package boundary (a runtime→client edge is a + build cycle): runtime's `route-ledger.conformance.test.ts` fails when a + dispatcher domain lands with no ledger entry and ratchets the audited gap + count (27 at PR-1); client's `route-ledger-coverage.test.ts` fails when a + ledger entry claims a client method that doesn't exist. Findings and follow-up slicing live + in `docs/audits/2026-07-dispatcher-client-route-coverage.md`. No runtime + behavior change. + +- 810a3a2: fix(runtime,cloud-connection): multi-tenant seed replay covers every source, not just the first (#3453) + + In multi-tenant deployments (enterprise `@objectstack/organizations`) a brand-new org + gets its own private copy of demo data by replaying the kernel's `seed-datasets` list + on the `sys_organization` insert. That list is meant to hold the union of every seed + source — every config-declared app AND every marketplace package — but two framework + traps (the same pair #3444 fixed for seed-summary) shrank it to just the first source: + + - The standard `PluginContext` exposes `getService`/`registerService` but has NO + `.kernel` handle, so `(ctx as any).kernel?.getService('seed-datasets')` always read + `undefined`. Each source then saw "nothing registered" and overwrote the list with + only its own datasets instead of extending it. + - `registerService` throws on a duplicate name, so the second source's re-register was + swallowed by the surrounding try/catch — its datasets (and, for a config app, its + replayer) silently lost. + + Net effect: with two config apps, or a config app plus marketplace packages, a new org + replayed only the first app's seeds. + + The fix mirrors #3444's seed-summary hardening: `seed-datasets` is now a single shared + array, registered once and mutated in place by every source through a new + `mergeSeedDatasets` helper that reads via the context's own resolver first. AppPlugin's + per-org replayer reads that live list at invoke time instead of a captured snapshot, so + it replays the full union — including datasets merged after its closure was built — and + the replayer itself is registered once and reused by later config apps. + + Covered by seam-level unit tests (accumulation across app + marketplace sources; the + replayer reads the live union). True multi-tenant end-to-end coverage requires the + enterprise `@objectstack/organizations` plugin, which lives in the cloud repo. + +- 9981c1d: Surface seed outcomes in the `os dev` / `os serve` boot banner (#3415). Seeds run inside the boot-quiet stdout window and SeedLoader's logs sit under the default warn level, so a fixture could silently lose most of its rows — the showcase shipped 1 of 5 projects with zero terminal signal. AppPlugin now stashes the per-boot seed counters on the kernel (`seed-summary` service) and the banner prints `Seeds: X inserted · Y updated · Z skipped`, escalating to a yellow `⚠ … N REJECTED` line when records were dropped. +- d60968c: Surface marketplace rehydrate/heal seed outcomes in the `os dev` / `os serve` boot banner (#3430), extending the config-app Seeds line from #3415. + + The seed pipeline's most useful result lines are all `logger.info`, but `os dev` forwards a default `warn` level and the serve boot-quiet window swallows stdout — so "marketplace package rehydrated onto a fresh DB with 0 rows", a fresh-DB self-heal, and row-level seed failures were all invisible unless you queried the database directly. + + The `seed-summary` kernel service is now a per-source list. AppPlugin (config apps) and the marketplace rehydrate/heal path each contribute a labelled entry, and the banner prints one combined line that ignores the log level: + + ``` + Seeds: showcase 162 rows · hotcrm(marketplace) 157 ok / 5 errors ⚠ + ``` + + Fresh-DB heals are marked `(healed on fresh db)`; a marketplace package that installed with seed datasets but landed 0 rows, and any run that dropped records, escalate to a yellow `⚠` line instead of passing silently. + +- e231abb: feat(objectql,metadata-protocol)!: single-source the protocol assembly; drop objectql's protocol re-exports — ADR-0076 Step 2 PR-C (#2462) + + The ONE assembly now lives in `@objectstack/metadata-protocol` as + `assembleMetadataProtocol()` — `createMetadataProtocolPlugin()` (delegated + mode, cloud) and `ObjectQLPlugin`'s built-in convenience mode + (`registerProtocol !== false`, single-kernel/dev boots) both mount the same + code path (~112 inline lines deleted from the engine plugin). objectql's six + protocol re-exports (`ObjectStackProtocolImplementation`, + `SysMetadataRepository`, `SeedLoaderService`, `runBuildProbes` + types) are + removed — import them from `@objectstack/metadata-protocol` directly + (breaking, shipped as minor per the launch-window convention; the only known + importers were five test files, repointed). Scope note vs the original Step-2 + recipe: the objectql→metadata-protocol dependency is deliberately KEPT for + the convenience mount — `@objectstack/objectql/core` was already + protocol-free, and forcing 20 framework boot sites to mount two plugins buys + no runtime win. "Zero protocol dependency" lands as "zero assembly ownership, + single source". + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [735f850] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [f92096b] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [984396b] +- Updated dependencies [d0fea33] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [cf5e033] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [ce1f100] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [307e0fe] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [aff9e56] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [ef5e72d] +- Updated dependencies [dac6a08] +- Updated dependencies [313d7be] +- Updated dependencies [5faeac6] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [4e9e184] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [8e08bc3] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [bbd902d] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [f1a8114] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [4c5a584] +- Updated dependencies [0c302a7] +- Updated dependencies [5cfd4d5] +- Updated dependencies [bd68f08] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [647ec8b] +- Updated dependencies [7457a09] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [a629074] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/rest@17.0.0-rc.0 + - @objectstack/driver-sql@17.0.0-rc.0 + - @objectstack/plugin-auth@17.0.0-rc.0 + - @objectstack/plugin-security@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + - @objectstack/metadata-protocol@17.0.0-rc.0 + - @objectstack/service-datasource@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + - @objectstack/service-i18n@17.0.0-rc.0 + - @objectstack/metadata@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + - @objectstack/driver-memory@17.0.0-rc.0 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.0 + - @objectstack/service-cluster@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7d4461449c..743d04332e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "16.1.0", + "version": "17.0.0-rc.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 8ba5341b1b..52a9f19282 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.0.0-rc.0 + ## 16.1.0 ## 16.0.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 4c09de1c65..c46dbb71c7 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "16.1.0", + "version": "17.0.0-rc.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 0b641c7d70..ff9d450c00 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,712 @@ # Changelog — @objectstack/service-analytics +## 17.0.0-rc.0 + +### Minor Changes + +- 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) + + Two independent defects on the `/analytics` surface, found while verifying #3770 + against a real server. On an authenticated CRM dev server, before this change: + + ``` + POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} + → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}], + "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"} + ``` + + That is SQLite's internal schema table — never a registered object — read + successfully through the analytics endpoint. Not merely "the name reaches the + driver and errors": **any table the connection can see was readable.** + + **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube` + auto-infers a minimal Cube when none is registered, with `cube.sql = `. That is the intended "metric over an object" path — an `object-metric` KPI + widget queries `crm_account` with no authored Cube — but it accepted _any_ string, + so the endpoint could aggregate over an arbitrary physical table. The + analytics-side twin of the data-path gap #3770 closed, and it was not covered by + that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded + fallback_; a deployment with `@objectstack/service-analytics` installed runs the + real engine instead (`ctx.replaceService`). + + Inference is now gated on the same schema registry the data path consults, via a + new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires + from the `data` engine's `getObject`. Three-way rule: a registered Cube runs + untouched (its `sql` is whatever it declares); an unregistered name that IS an + object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404 + raised before any SQL exists, naming both ways to make the request valid. With no + probe configured the gate stands down and warns once — the same tiering #3770 + took for a missing registry. `generateSql` (`/analytics/sql`) is gated too. + + **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase` + is the single error exit for _every_ route the dispatcher plugin mounts — + `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, + `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against + driver dumps forever (`mapDataError`); this boundary guarded nothing, so any + driver error on any of those routes shipped its SQL to the client. Unlike ①, this + half is unconditional — it does not depend on the cube being invalid. + + The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as + `looksLikeInternalErrorLeak` (both packages already depend on it) and is now + applied at both boundaries — one predicate, one place to widen when a new + dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the + dispatcher it applies **only to 5xx**: a 4xx message is a deliberate + business/validation answer and must reach the caller intact. Sanitising costs no + diagnostics — the untouched error still reaches `errorReporter` through the + existing `__obsRecordedError` side-channel. + + **Also fixed in the same function:** `errorResponseBase` read only + `err.statusCode`, while domain errors across this codebase carry `status` (and + `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate + 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on + the analytics fallback path — was rendered as a **500**. It now reads `status` + then `statusCode`. + + **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404 + `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object; + previously the name was passed to the driver. Dashboards and KPI widgets pointed + at real objects or authored cubes are unaffected. A 5xx on a dispatcher route + whose message looks like a driver dump now reads `Internal server error` — check + server logs or your error reporter for the original. + +- 587fc91: feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt + + The analytics→engine bridge now forwards the request's `ExecutionContext` to + `engine.aggregate`, so the engine's own middleware chain scopes analytics reads + independently of the analytics layer's `getReadScope`. + + **Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing + forced the bridge to pass it — and it did not. An authenticated aggregate + reached the engine with no principal, plugin-security's principal-less fall-open + skipped its RLS injection, and the only thing left scoping the query was the + strategy remembering to call `getReadScope`. #3597 was a strategy that did not, + and both belts were off at once. + + `getReadScope` stays: the two resolve scope through different paths (engine + middleware vs `security.getReadFilter`), and a deployment without + plugin-security has only the analytics layer. This is depth, not a replacement. + + - `StrategyContext` gains `context?: ExecutionContext`, bound per call by + `AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`. + - `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` / + `AnalyticsService` `executeAggregate` config options gain `context?: +ExecutionContext`. **Custom bridges should forward it** to their engine; the + built-in auto-bridge does. Purely additive — an existing bridge that ignores + it keeps working exactly as before. + - `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain + an optional trailing `context`, beside the `scope` / `resolveScope` that + #3639 added — the same two-belt split as the aggregate path. + - `BootOptions.analytics` (`@objectstack/verify`) overrides the + AnalyticsServicePlugin instance, so a gate can boot with the analytics belt + off and assert the engine-side belt alone still scopes. + + **Also fixed on the same seam:** + + - `fetchRecordLabels` — the dimension display-label lookup — is row-granular + (one row per record, real display names). #3639 gave it the analytics-layer + belt (the referenced object's own read scope); it now also carries the + context, so the engine scopes the same read independently. + - `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the + `/analytics/sql` preview read as an unscoped table scan while the real + aggregate was scoped. It now renders the caller's filters and the read scope. + The preview never executed, so this was misleading output rather than a leak. + +- 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582) + + Filter values travel as JSON, so a time- or user-scoped slice writes a + placeholder instead of code: + + ```ts + filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' } + ``` + + The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`, + `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it + (#3574). What was missing is the half that _substitutes a value_: **nothing on + the server ever did**. A placeholder reached the driver as the literal string + `'{current_year_start}'`, compared as text, and matched nothing. + + That failure is invisible — an empty widget looks exactly like a metric that is + legitimately zero — so apps worked around it by computing dates at module load, + which freezes "this year" into the built artifact and quietly goes stale. + + **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two + server-side seams every filter passes through: + + - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST + queries, related lists, saved-view filters and flow `find_records` all resolve. + It runs before the middleware chain, so only author-supplied filters are + inspected; RLS/sharing filters are injected downstream from concrete values. + - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's + `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s. + This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds + comparands directly, so a dashboard widget never passes through `engine.find()`. + + Behavioural notes: + + - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for + `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's + on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so + there is still exactly one source of truth for the storage convention. + - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned + per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair + can never straddle a boundary. + - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads + `userId`. A request carrying neither now **throws** instead of resolving to + `null` — a null comparand degrades to `IS NULL` on most drivers and would hand + back the rows the filter was written to exclude. + - An unrecognised placeholder **throws**, carrying the near-miss fix + (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` → + `{current_quarter_start}`). This matches what `objectstack build` already + enforces. Consequence, previously implicit and now load-bearing: a filter value + that is _entirely_ `{...}` is always read as a placeholder, so a literal value + of that shape is not expressible — rename the value. + + Also in this change: `notify` no longer sends the six-character string + `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks + `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)` + turned that into a phantom recipient — the emit "succeeded", addressed nobody, + and said nothing. Unresolved recipients are now dropped, and a node with no + recipient left fails naming the offending template and pointing at the start + node's `config.expand` (#3475), which does hydrate the relation. + +- fc5f126: feat(analytics): serve in-envelope cross-object grouping on the ObjectQL path by FK-expand (#3654) + + `engine.aggregate()` cannot join, so the ObjectQL fallback path (date-granularity + bucketing, in-memory driver, federated objects) previously REJECTED any + cross-object grouping like `revenue by account.region` (#3664 stopgap — a loud + error instead of the earlier silent `(null)` mis-bucket). It now SERVES the + common case directly. + + For a single-hop cross-object DIMENSION with recombinable measures, the strategy: + + 1. groups the base aggregate on the lookup FK column (`account`) — which the + engine can do — scoped to the base object; + 2. resolves each FK id to the related attribute (`region`) with a read of the + referenced object **scoped to that object's own RLS**; then + 3. re-buckets by the resolved attribute in memory, recombining the measures + (sum/count add; min/max take the extremum). + + A base row whose referenced record the caller cannot read buckets under an + explicit `(restricted)` group: its measure still counts (grand totals are + preserved) but the hidden record's attribute never appears — no leak (ADR-0021 + D-C, the #3602 class). `/analytics/sql` renders the equivalent `LEFT JOIN`. + + Deliberately bounded — still REJECTED (loud, never silently wrong): cross-object + references in a MEASURE or FILTER (need a real join to evaluate), multi-hop + dimensions (`a.b.c`), and non-recombinable measures (`avg`, `count_distinct`) + with a cross-object dimension. Cross-object queries on `NativeSQLStrategy` (the + normal SQL path) are unchanged — it hand-compiles the joins. + +### Patch Changes + +- c7f4417: fix(driver-sql,analytics): stop `aggregate()` / `distinct()` leaking SQLite's raw epoch storage (#3797) + + Both returned `await builder` directly, without the `formatOutput` pass every + `find()` row gets. On SQLite — the one dialect where a `Field.datetime` is + stored as INTEGER epoch milliseconds rather than a native timestamp — that raw + storage form went straight to the caller: + + | call | before | after | + | -------------------------------------- | ---------------------------- | -------------------------------- | + | `find()` | `"2026-01-10T09:00:00.000Z"` | unchanged | + | `distinct('closed_at')` | `[1768035600000]` | `["2026-01-10T09:00:00.000Z"]` | + | `aggregate()` `max(closed_at)` | `1768035600000` | `"2026-01-10T09:00:00.000Z"` | + | `aggregate()` `groupBy: ['closed_at']` | key `1768035600000` | key `"2026-01-10T09:00:00.000Z"` | + + Same root cause as #3773, different exit. `Field.date` was never affected — it + is ISO TEXT on every dialect, so its storage form already equals its + presentation. + + The visible surfaces were a `_max`/`_min` measure over a datetime (a "last + closed" KPI tile rendered `1768035600000`) and a `groupBy` on a raw datetime + dimension, which also disagreed with the in-memory `applyInMemoryAggregation` + fallback — that one consumes already-formatted `find()` rows, so the same + dataset changed key type depending on which path served it. + + Which columns hold an instant is now recorded while the statement is built, + because that is the only point where a column name and its meaning are both + known: a `min()` lands under its alias and never under the field name, while a + date-BUCKETED column lands under the field name but holds a label (`'2026-01'`) + rather than an instant. Matching on names afterwards gets both backwards. + + `distinct()` additionally re-deduplicates after presenting: SQL `DISTINCT` + compares STORED values, and one SQLite datetime column holds both INTEGER and + TEXT forms, so two rows recording the same instant survived as two and then + presented identically. It has no in-repo callers today; this keeps it honest + rather than leaving a second convention in the driver. + + **`cross-object-rebucket` was fixed alongside it, because presenting min/max + correctly is what exposed it.** `recombine()` coerced every operand with + `Number()`, which silently depended on receiving an epoch: handed the ISO string + the driver now returns it produced `NaN`, and on Postgres/MySQL (where knex + returns a `Date`) it had always flattened the value back to an epoch integer one + layer above the driver. `min`/`max` now order by the instant and return the + winning value in the shape it arrived in; `sum`/`count` stay numeric. + +- 7101ca2: fix(analytics): apply the EFFECTIVE date granularity to bucket labels and drill ranges (#3588 follow-up) + + `selection.dateGranularity` (shipped in #3652) reached the `GROUP BY` but not the + post-processing: the bucket-label formatter and the drill-range inverter both + kept reading the DATASET dimension's default. A query was grouped one way and + described another. Found by driving a real dashboard query in a browser against + a dataset whose dimension declares `dateGranularity: 'month'`: + + - selection `year` → the row came back labelled **`1970-01`** — a year bucket + re-formatted with the dataset's month granularity, its `"2026"` key re-read as + 2026 _milliseconds_ past the epoch; + - selection `day` → day buckets were re-labelled as months, so ten distinct days + collapsed into two duplicated keys; + - selection `quarter` / `year` / `day` / `week` → `drillRanges` came back empty, + silently removing drill-through from every bucketed chart. + + Granularity precedence now lives in one exported function, + `resolveDimensionGranularity`, called from all three sites that must agree — the + query's `GROUP BY`, the label formatter, and the range inverter. The drift was + possible only because each site resolved it independently. + + Two consequences beyond the override case: + + - A dataset dimension that declares **no** granularity but is bucketed by the + widget now gets drill ranges too. Previously the range sidecar keyed off the + dataset's own `dateGranularity`, so this case — the one #3588 is actually + about — could never drill. + - `formatDateBucket` no longer mistakes a bare year key for an epoch timestamp. + A year bucket's canonical key IS `"2026"`, which is the only bucket key that + collides with the pure-digit epoch heuristic (`"2026-Q2"`, `"2026-07"` and + `"2026-07-15"` all fail it). Being idempotent over already-formatted keys is + that function's stated contract; the year case just never held. + +- 415254c: fix(analytics): scope the dimension-label lookup to the referenced object's RLS (#3602) + + When a dataset groups by a `lookup`/`master_detail` dimension, analytics resolves + the grouped FK ids to the related record's display name via a per-record read + (`group by id`) dressed as an aggregate. That read carried **no read scope**, so + it revealed related-record display names whenever the referenced object's RLS is + stricter than the base object whose rows carry the id — a user could see a name + the referenced object's own RLS would hide. (Same-object and looser-referenced + cases were already safe because the ids come from the post-#3597 scoped + aggregate; this closes the stricter-referenced case.) + + The label lookup now applies the **referenced object's own** read scope — bound + to the request via the same `getReadScope` provider the aggregate path uses, + composed with `$and` (never key-merge) so it can't be displaced by the id + predicate. Fail-closed: if that object's scope can't be resolved, the dimension's + labels are skipped (the raw id renders) rather than fetched unscoped. No behaviour + change when no read-scope provider is configured. + + Internal `DimensionLabelDeps.fetchRecordLabels` gains an optional `scope` argument + and `resolveDimensionLabels` an optional `resolveScope` resolver; both are + service-analytics-internal (no spec/contract change). + +- 1f8390b: fix(analytics): ObjectQLStrategy now enforces the read scope (RLS + tenant) (#3597) + + `ObjectQLStrategy` never consumed `getReadScope`, so any analytics query served by + that path ran with **no RLS or tenant predicate** — an authenticated caller + received aggregates computed over every tenant's rows. + + Both belts were off at once. The strategy dropped the pre-resolved read scope, and + the engine could not compensate: the `executeAggregate` bridge passes no + `ExecutionContext`, so plugin-security's principal-less fall-open skipped its own + RLS injection. Only `NativeSQLStrategy` was ever wired for ADR-0021 D-C. + + The exposure was **not** limited to exotic drivers. `NativeSQLStrategy` declines — + handing the query to this path — on any date-bucketed query + (`timeDimensions[].granularity`, the most common dashboard shape, on Postgres and + SQLite too), on `RAW_SQL_UNSUPPORTED` (in-memory driver), and on federated objects. + + The scope is composed with `$and`, never by key merge, so a caller filter naming + the same field (e.g. `organization_id`) cannot displace the security predicate. + + **Behaviour change to be aware of:** a query that references a **joined** object + carrying its own read scope is now REJECTED on this path rather than run + partially-scoped. `engine.aggregate`'s `where` addresses the base object, so a + per-join predicate cannot be expressed there; failing closed matches the posture + already taken by `resolveReadScopes` and `compileScopedFilterToSql`. Such a query + previously returned results that omitted the joined object's tenant predicate. + Run it on a native-SQL driver (`NativeSQLStrategy` scopes each join), or drop the + cross-object dimension/measure. + + Deployments with no read-scope provider configured are unaffected — that path + stays unscoped by documented contract. + +- 3167e29: fix(analytics): sort dataset selections by the display label for select/lookup dimensions (#3680) + + `DatasetSelection.order` (what a widget's `options.sortBy` lowers to) sorted a + `select` or `lookup`/`master_detail` dimension by its STORED value — the option + value or the foreign-key id — while the response rows carry the resolved display + label. A "sort by Account" therefore ordered by opaque ids and read as arbitrary; + a localized select sorted by its ASCII value while showing a non-ASCII label. + + Order keys naming a label-bearing dimension now sort by the display label the + user reads. The executor receives an injected sort-key hook (`OrderLabelResolver`, + built by `queryDataset` over the same label-resolution capabilities and #3602 + read scoping as the display pass); only the COMPARISON substitutes the label — + rows keep their raw values until the display pass, so drill metadata still + snapshots stored values, and ordering + windowing stay one adjacent step (a + "top 10 by account name" truncates the right ten). + + Cost model: sorting by a measure or a plain/date dimension is unchanged (SQL + pushdown included). A label-ordered `select` resolves from field metadata (no + query). A label-ordered `lookup` costs one batched id→name read over the + pre-window grouped ids (chunked, and reused by the display pass via a + per-request cache), and its window can no longer be pushed into SQL — the + inherent price of ordering by a value the database doesn't store. + +- 0a6fb1e: fix(analytics): the read-scope auto-bridge no longer depends on plugin order (#3618) + + `getReadScope` was only wired when the `security` service already existed at this + plugin's `init()`. The closure itself resolved lazily, but the ASSIGNMENT was + gated on an init-time probe — so a kernel that registers `AnalyticsServicePlugin` + before the security plugin got **no read-scope provider at all**, and every + analytics strategy ran unscoped with only a WARN to show for it. + + Both sibling bridges (`executeAggregate`, `executeRawSql`) are wired + unconditionally and resolve at call time, and this one's own comment claimed the + same. Now it actually does: the probe only decides the log wording. + + The CLI (`os serve`) registers security before analytics, so that path was + already correct. The exposure was for embedders composing their own kernel — and + for this repo's own `bootStack` harness, which registers analytics first, meaning + the entire dogfood/verify suite had analytics RLS silently disabled and any RLS + assertion written there passed vacuously. + + Also corrects the WARN text: with no provider, scoping is absent on ALL paths and + ALL objects, not just "the raw-SQL path" and "joined objects" as it claimed. + + Adds `analytics-rls.dogfood.test.ts`: an owner-scoped RLS fixture driven over real + HTTP as a real non-admin, asserting the rows a member's aggregate actually + returns. Reverting either this fix or the #3597 strategy fix turns it red. + +- 1986594: feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588) + + Three presentation options were accepted by the metadata layer and then dropped + by the analytics query builder. They reached no SQL, produced no error, and the + only way to notice was to read the `sql` a dataset response echoes — so a + dashboard could declare `dateGranularity: 'month'` and quietly render one bar + per record. + + - **`dateGranularity` now buckets.** `DatasetSelection` gained an optional + `dateGranularity`, applied to every selected `date` dimension. Precedence per + dimension: an explicit `timeDimensions` granularity, then the selection's, + then the dataset dimension's own default. A widget can bucket a trend by month + without the dataset committing every other consumer to that granularity. + - **`order` / `limit` / `offset` now apply on every path.** They are applied to + the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo` + columns attach, and after derived measures are computed — so a derived measure + is a valid sort key and the ObjectQL aggregate path (which has no ordering + grammar, and which native SQL hands every date-bucketed query to) orders + identically to native SQL. A single-query selection still pushes the window + down into the statement. An `order` key that names nothing the selection + projects is now rejected (400) rather than silently ignored. + - **`limit` is deterministic.** Without an `order`, a limit orders by the + selected dimensions first, so it truncates a reproducible window instead of an + arbitrary subset. + - **Widget `options` is a contract again.** The four query-affecting keys + (`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are + declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is + an author-time error. The bag stays open — renderer extras (`icon`, `columns`, + `striped`, …) pass through untouched. + + Two latent bugs surfaced while fixing the above and are fixed here too: + + - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped + supplementary query selects one measure, so an inherited `ORDER BY` named a + column it never selected, and an inherited `LIMIT` truncated it before the + merge — dropping rows from the assembled grid. Nothing hit this only because + nothing passed `order`. + - The `compareTo` pass built its query by hand and skipped granularity + resolution, so a month-bucketed primary grid was merged against raw-timestamp + comparison rows. No dimension key matched and every `__compare` + column came back empty. + + `ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`, + `WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined). + Previously the `sql` field simply vanished from the response whenever a query + was date-bucketed, leaving an author unable to tell "not implemented" from "this + strategy doesn't report". + +- a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839) + + A grouped row whose dimension value is empty now carries `null` for that + dimension no matter which way the aggregate ran. Downstream code can test the + empty bucket with a plain `value == null` again: charts render their own empty + label, drill-through on that bucket builds `field = null` and returns the rows + it should, and a dashboard no longer changes shape when the driver, the + granularity or the reference timezone changes. + + ### What was wrong + + `engine.aggregate` has two implementations of one feature. It pushes the + aggregate down as SQL when the driver advertises every requested granularity and + the reference timezone is UTC; otherwise it fetches rows and buckets them in JS. + The two disagreed about how to spell "empty": + + ``` + --- same dataset, same query, one row with a NULL value --- + pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …] + in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …] + ``` + + The measures were always right — only the key's type and literal differed — + which is why this went unnoticed for so long: every total reconciled. But the + engine picks a path per query, so the same data produced a different bucket key + on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise), + a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of + which bucket in memory unconditionally. + + It was never date-specific either. A plain `groupBy: ['stage']` over a NULL + column diverged the same way. + + Consumers are written against `null` — they check `== null` and supply their own + empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated + every one of them: it rendered a raw English debug string in the UI, and a drill + on the empty bucket compiled to `field = '(null)'` and matched nothing. + + The in-memory path's comment justified the string as staying "consistent with + the client `useReportData` hook". That hook was removed with ADR-0021, and the + literal never appeared in it. + + ### What changed + + - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key + the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A + null instant and an unparseable one still share one bucket, because SQL cannot + tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL). + - The internal composite bucket id is JSON-encoded, so the empty bucket stays + distinct from a row whose value is the literal string `"null"`. + - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The + empty bucket has no calendar span, so a drill on it opens the unscoped + superset instead of an invented bound — unchanged behavior, honest signature. + - The driver output contract in `@objectstack/spec` now states the rule: a row + with no value keys as `null`, never a sentinel. Propagating NULL through the + bucket expression is the whole of it; a driver only breaks it by adding a + `COALESCE`. + + ### Gates + + `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null + instant, because the divergence would have failed it for a reason it was not + about. Its fixture now has one, so the convergence is held in place — including + for out-of-tree drivers that run the check against themselves. + + Two fixes were needed to make that fixture meaningful: + + - The check folded bucket labels through `String(value)`, which turns SQL NULL + into `'null'` — a label a TEXT column can genuinely hold. A driver spelling + "empty" as a string could compare equal to one returning real NULL. The empty + bucket is now keyed out of band. + - Label sets were compared with `JSON.stringify`, which is sensitive to key + insertion order. Row order is not part of this contract and the two paths + naturally differ (SQL sorts its groups; the in-memory path emits first-seen + order), so a driver with entirely correct buckets could be reported as + disagreeing — with an empty diff message, since nothing actually differed. + The comparison is now order-insensitive. + + A new dogfood check covers the non-date half against real drivers: same dataset, + plain and date-bucketed `groupBy`, both paths, one key. + +- adabaa8: fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654) + + `engine.aggregate()` has no join — it never expands a lookup and the SQL driver's + aggregate emits no `JOIN`. So a dotted dimension/measure like `account.region` + reaching `ObjectQLStrategy` (the fallback NativeSQL declines: date-granularity + bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory + path bucketed every row under one `(null)` group and summed the whole table into + it (a plausible number that is actually a mislabelled full-table total), and the + native path errored on the unresolved column. + + `ObjectQLStrategy` now rejects any cross-object reference outright, with a clear + message, before the query reaches the engine. This generalizes the #3597 guard + (which only rejected when the joined object carried a read scope, and skipped the + check entirely when no read-scope provider was configured — so the silent + `(null)` bucket still shipped on unsecured/in-memory setups) into an + unconditional one, and subsumes it: a rejected query never loads the joined + object, so there is nothing left unscoped. + + Cross-object datasets are unaffected on `NativeSQLStrategy`, which hand-compiles + the LEFT JOINs (and scopes each). This only changes the fallback path, turning a + silent wrong answer into a loud, actionable error. Full lookup-traversal support + in the aggregate path is left as follow-up (see #3654). + +- 605c23f: fix(analytics): ObjectQLStrategy applies `timeDimensions[].dateRange` — the predicate every date-bucketed chart was missing (#3650) + + `ObjectQLStrategy.execute()` built its engine filter purely from + `normalizeAnalyticsFilters(query)`, which reads only `query.where`. But + `dateRange` is a **sibling** of `where`, never folded into it — so the window + was dropped on the floor. No error, no warning: the chart rendered, and the + numbers were for all of history. + + This was not a "some drivers only" corner. `NativeSQLStrategy.canHandle` + declines any query carrying a `granularity`, so a **date-bucketed trend lands on + the ObjectQL path on every driver**, Postgres and SQLite included — and a + bucketed trend is precisely the shape that also carries a range ("last 12 + months", "this quarter"). The other two paths always applied it + (`NativeSQLStrategy` as `BETWEEN`, `preview-evaluator` row-wise); only this one + did not. + + **Two visible symptoms:** + + - A trend chart with a time filter plotted **every row ever recorded** instead + of the selected window. + - `compareTo` (period-over-period) was **structurally dead**. `runCompare` + builds the comparison pass by shifting `dateRange` and changing nothing else, + so with the window ignored both passes issued a byte-identical aggregate: + every `__compare` column equalled its primary and the delta was a + flat 0%. And since `compareTo` requires a time dimension, it always took this + path. + + The window now lowers to an inclusive `{$gte, $lte}` on the resolved field — the + same shape `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds + as a `$match` — so one dashboard reads the same on every driver. No storage + coercion is applied here on purpose: unlike the raw-SQL path (which had to learn + about SQLite's INTEGER epoch in #2034), this path goes through + `engine.aggregate()`, where the driver's own CRUD filter coercion already + handles a `where` bound on that same column. + + **Same-field composition was fixed alongside it**, because the window makes it + routine. Operands merged into one field entry by spreading, which silently kept + whichever came last: a `where` bound and a window bound on `close_date` would + have had one erase the other, and a `where` that names one field twice through + `$and` (`{$and: [{stage: 'won'}, {stage: {$ne: 'lost'}}]}`) already lost its + first operand today. Operands that name **different** operators still share one + entry; colliding ones become their own `$and` conjunct, so the engine + intersects them instead of the strategy picking a winner. + + `generateSql()` renders the window as a parameterised `BETWEEN` to match — its + comment previously explained why a `BETWEEN` was deliberately absent, which was + correct only while `execute()` dropped the window. Bounds bind as `$n` + placeholders, never inlined: the echoed statement travels to the browser. + + A window on a **cross-object** time dimension is still rejected, and is now + reported as the bucketing error it is rather than as the "cross-object filter" + its lowered predicate would otherwise resemble. `execute()` and + `/analytics/sql` continue to accept and reject the same set. + + Relative-phrase ranges ("Last 7 days") are still not resolved on this path, and + a bare-string `dateRange` degenerates to a single point — both matching + `NativeSQLStrategy` exactly, rather than inventing a second interpretation for + the driver-independent path. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index cc77d0f169..87bb7149dc 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "16.1.0", + "version": "17.0.0-rc.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 0a48b05b3e..85aa13610c 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,903 @@ # @objectstack/service-automation +## 17.0.0-rc.0 + +### Major Changes + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +### Minor Changes + +- 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801) + + `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied + `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and + `resumeInternal` validated **machine state only** — the concurrent-resume latch, + the run exists, the flow exists, the suspended node still exists. Nothing asked + _who was calling_. + + Approval nodes suspend and resume through exactly that mechanism. So a resume + carrying `branchLabel: 'approve'` walked the approve edge with **no approver + check, no `sys_approval_action` row and no status mirror** — the + `sys_approval_request` row and the run then disagreed permanently. The only + thing standing between the route and the approvals rules was convention; the + showcase spelled it out in a comment ("decide via the approvals API, never a raw + engine `resume`"), and a comment in an example is not an access control. + + Removing the route was not the fix: it is load-bearing for **screen flows** — + the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node. + The gate therefore keys on **what the run is parked on**: + + - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) — + a pausing node declares who may continue it. `approval` declares `'service'`. + - The engine refuses a `'service'` suspension unless the signal carries + `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the + owning service stamps in-process — a JSON body can never produce one, so the + transport cannot forge it. `ApprovalService` stamps it on the tail of a + decision it has already authorized and recorded. + - The gate follows a **subflow** pause down to the child the signal would + actually reach, so resuming the parent is not a way around it. + - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers + **403**. Nothing is consumed — the request stays pending and the run stays + parked, so the real decision still lands. + + `screen` and `wait` pauses are unchanged, as is every path that already went + through the approvals API. What changes for consumers: + + - **FROM:** finishing an approval with + `client.automation.resume(flow, runId, { branchLabel: 'approve' })` + **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`). + The old call now answers 403 and changes nothing. + - Registering your own pausing node whose continuation belongs to a service + rather than to whoever holds the run id? Declare `resumeAuthority: 'service'` + on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that + service. + + A suspension now records the node type that produced it + (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend + time so a flow republished mid-pause cannot re-type the node out from under the + gate; rows written before this fall back to the flow definition. + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +- 2f47489: fix(automation): a `fault` edge must not switch off a guardrail (#3863) + + A `fault` edge routes a failed node to a handler instead of aborting the run. + That is the right primitive for the world not cooperating — an `http` node that + 404s, a connector that rate-limited, a rejected write. + + It was also, until now, routing the **refuse-to-execute** family. Those guards + report that the METADATA is wrong, not that an operation failed: #3810 + (interpolation erased a filter condition), ADR-0049/#1888 (the run would execute + unscoped), a data node naming no object. Because they surfaced as ordinary node + failures, one declared edge silently disabled them. + + **The live consequence, reproduced in a test before the fix:** attach a `fault` + edge to a `delete_record` whose filter has a typo (`{record.ownr}`), and #3810's + protection against emptying the object was gone — the guard fired, the handler + swallowed it, and the run reported `success: true`. That is the exact fail-open + direction #3810 was opened to close, reachable from a single edge, and it is the + kind of suppression an AI authoring loop reaches for first when trying to make a + diagnostic go away. + + **Failures now carry a class.** `NodeExecutionResult.errorClass` is `'runtime'` + (default — every existing executor keeps its current routing) or `'guard'`. + Guard-class failures are never routed: they stay fatal with or without a `fault` + edge, and the run fails with the guard's own message. Thrown guards are covered + too — `UnscopedRunDataAccessError` is branded via a shared `guard-refusal` + module, so the engine's catch path cannot become the bypass the return path no + longer is. + + Marked as guard-class: the three `resolveNodeFilter` refusals (#3810), the four + `objectName required` refusals, and `UnscopedRunDataAccessError` (ADR-0049). + Genuine engine failures (`get_record(x) failed: …`) stay runtime-class and keep + routing. + + **Also in this change** + + - `{.error}` now carries a failed node's message alongside the run-wide + `{$error}`. `$error` names only the most recent failure, so a handler shared by + two fault edges could not tell which node it was handling; `{charge_card.error}` + is addressable from any downstream template. Additive — `$error` is unchanged. + - Fault edges are **documented** for the first time (`content/docs/automation/flows.mdx` + and the automation skill), including the routable/not-routable split. The skill + entry says plainly not to add a fault edge to silence a guard error, since that + is the misuse the class split now makes impossible. + + A run that takes a fault branch still reports success, and the failed step still + carries `status: 'failure'` and its message in the trace — recovery does not + erase the record of what failed (#3356/#3407). + +- de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810) + + Three related holes, all of which end in "the query matched rows the author + excluded". + + **1. A flow filter could silently widen to match everything.** + + The flow template interpolator expresses "this token did not resolve" as + `undefined`. In a message that renders as empty text — harmless. In a FILTER it + removes the condition, and a removed condition matches MORE rows. When it was + the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to + `deleteMany` is every row in the table. + + So one mistyped field name in a `delete_record` node silently emptied the + object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the + run never received, a lookup hop (`{record.account.name}` — the trigger record + carries a scalar id), and a filter placeholder. + + `get_record` / `update_record` / `delete_record` now refuse to execute when + interpolation erased any authored condition, naming the offending template. The + guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is + unaffected, and losing one of two conditions still fails, because widening from + "my open records" to "all open records" is the same class of bug. + + **2. Filter placeholders never reached the engine that resolves them.** + + `config.filter` is where two `{…}` dialects meet — the flow template dialect + (`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`, + `{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order + picked the winner by accident: the flow interpolator ran first, found no flow + variable by that name, and erased it. + + `interpolateFilter()` hands that position back to the dialect that owns it — a + whole-string token that no flow variable resolves and that IS a recognised + placeholder passes through verbatim for the engine to expand. Flow variables + keep precedence, so a template that works today cannot change meaning. + + **3. The engine resolved placeholders on reads but not on writes.** + + `resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So + the SAME filter selected different rows depending on the verb: `find({ owner: +'{current_user_id}' })` matched the signed-in user's rows, while + `update`/`delete` compared the literal token text and matched none — a flow that + previewed with one and acted with the other operated on two different row sets. + This is the #3106 shape one layer down: the evaluator existed, only some call + sites reached it. + + `update` and `delete` now resolve too, BEFORE the by-id fast path claims a + scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as + the primary key itself). Caller options are never mutated. + +- 5524f84: feat(automation): opt-in single-hop lookup expansion for record-change flow templates (#3475) + + A record-change flow can now declare `expand: ['', …]` on its start + node config so node templates resolve `{record..}` (e.g. + `{record.account.name}` in a notify title, closing the #3426 gap for lookups). + + The engine re-reads the declared relations AFTER identity resolution, as the + run's OWN principal — `resolveRunDataContext` honors `runAs`, so a `runAs:'user'` + run reads the referenced object as the **triggering user** (its RLS/FLS enforced) + rather than system-elevated. This is what made expansion unsafe to do in the + trigger's re-read (which has no resolved grants) and is why it lives in the + engine (new `AutomationEngine.setRecordExpander`, bridged by the plugin to the + same data engine the CRUD nodes use). + + Only the declared relation keys are grafted onto the run record, so bare lookup + ids and `multiple` lookup arrays (#1872) on other relations — and the formula + fields the trigger already hydrated — are untouched. Opt-in ⇒ zero cost when + unused; best-effort ⇒ a re-read failure leaves the record unexpanded and never + breaks the flow. + + The `os validate` lint rule `flow-template-lookup-traversal` (#3426/#3472) is now + suppressed for a relation once the flow declares it in `config.expand`. + +- 7687f7b: fix(automation): a screen field's `visibleWhen` reaches the client (#3528) + + `visibleWhen` has been on the `screen` node's designer form since #3304 — + declared as an expression (`xExpression`), documented as bare CEL, offered to + authors in Studio. The executor never put it on the wire. `ScreenFieldSpec` + carried `name` / `label` / `type` / `required` / `options` / `defaultValue` / + `placeholder` and nothing else, so no client could honour a predicate it never + received. Authors wrote conditional visibility; every field rendered + unconditionally; nothing errored. + + That is worse than a cosmetic miss, because `required` **is** honoured. A field + that is optional-by-design but required _when shown_ becomes permanently + required once its predicate is dropped — and a runner that validates the full + field list then blocks Submit on input the user was never asked for. No resume + request is issued and the run sits paused forever. HotCRM's lead-conversion + screen is exactly that shape: + + ```ts + { name: 'createOpportunity', type: 'boolean', required: true }, + { name: 'opportunityName', type: 'text', required: true, + visibleWhen: 'createOpportunity == true' }, + ``` + + Leave the checkbox unticked and `opportunityName` — which should not be on + screen at all — blocks the whole conversion. + + - `ScreenFieldSpec.visibleWhen` is now part of the contract, documented as + client-evaluated bare CEL over the screen's own field names, with the + `required`-must-follow-visibility rule stated where implementors will read it. + - The `screen` executor forwards it **raw**, deliberately uninterpolated: the + predicate is re-evaluated per keystroke against values only the client has, so + resolving it server-side against flow variables would freeze the field. + - Covered by tests — the screen wire payload had none for this key. + + Clients must evaluate the predicate and skip hidden fields when enforcing + `required`. Honouring one without the other reproduces the dead-end above. + +- b95577a: feat(automation): surface silently-stripped write fields as step warnings (#3407) + + `update_record` used to report an unconditional `success` even when the data + layer legally stripped the requested write fields — static `readonly` (#2948) + or a TRUE `readonlyWhen` predicate (#3042). The only trace was a server-side + logger warn, invisible in the flow run trace: an author saw a clean 3ms + `success` while the DB truth never changed (how #3356's approval stage + write-backs failed unnoticed). + + - **spec**: new `DroppedFieldsEventSchema` / `DroppedFieldsEvent` + (`{ object, fields, reason: 'readonly' | 'readonly_when' }`) in + `data/data-engine.zod.ts`, and a `WriteObservabilityOptions` + (`onFieldsDropped` listener) mixin on `IDataEngine.insert/update` option + params in `contracts/data-engine.ts`. The listener is a TS-contract-level, + in-process-only channel — deliberately NOT part of the serializable Zod + options schemas or the RPC boundary. + - **objectql**: `engine.update()` reports each strip pass's dropped keys + + reason through `options.onFieldsDropped` (all four strip sites: single-id + + bulk × readonly + readonlyWhen). A throwing listener never breaks the write. + System-context writes skip the readonly strip and therefore report nothing, + as before. `insert()` accepts the option for symmetry but strips nothing + today (INSERT is readonly-exempt; FLS write denial throws). + - **service-automation**: `NodeExecutionResult` and `StepLogEntry` gain + advisory `warnings?: string[]`; `update_record` / `create_record` attach one + warning per strip event naming the dropped fields, plus a structured + `droppedFields` output (`{.droppedFields}`) for downstream nodes. + `success` semantics are unchanged — stripping stays legal, it just is no + longer silent. + +### Patch Changes + +- b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) + + The record lock is keyed on a **pending** `sys_approval_request`, and it could + not tell _the run that owns that request_ from _an unrelated user editing the + record_. So a flow that touched its own target record while its own approval was + still pending — a manual `resume` with no decision, or a node that writes the + record between opening the approval and the decision — died on its own + `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery + existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it + self-healing. + + Both halves are now closed. + + **Prevention — the owning run may write its own record.** The automation engine + stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it + travels with every data node's ObjectQL context into `ctx.provenance`. The lock + hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`. + It is keyed on run identity rather than elevation on purpose: a `runAs:'user'` + run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance — + server-constructed like `isSystem`, never client-supplied, evaluated by no + security middleware, and the only write it permits is to the one record its own + run already holds a pending request against. + + **Recovery — a sweep releases records held by runs that died anyway.** A pending + request whose owning run has reached a terminal state (`completed`, `failed`, + `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled` + — releasing the lock — and audited under the reserved actor `system:dead-run` + with the run and its status in the comment, so it is never mistaken for a + submitter's withdrawal. It runs on the existing approvals sweep clock, which also + covers the case no in-band handler can: a run killed by a process crash. + + The sweep is fail-safe by construction. It acts only on an explicit terminal + status from a closed set; `paused` (the normal state of a live approval), + `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a + deployment with no automation engine are all read as "still alive". The failure + mode is "a dead run's lock survives until an admin recalls it" — today's + behaviour — never "a live approval is destroyed". + + Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for + a run id rather than the latest. A run that pauses and later finishes records two + entries under one id, so every suspend-then-finish run — every approval, screen + and wait flow — reported itself as `paused` forever, both on the Runs + observability surface and to this sweep. + + One shape was left out here and closed separately in #3712: a `runAs:'user'` run + with no trigger user (a schedule) resolved no ObjectQL context at all, so it + carried no `flowRunId` and stayed subject to the lock. It now passes a + provenance-only context — the run id and nothing the security middleware keys on + — so it is attributable without acquiring a principal, and its documented + unscoped posture (#1888) is unchanged. + +- c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712) + + #3456 let the run that opened a pending approval write its own target record, + keyed on `flowRunId`. It worked for every run that resolves an identity and + missed the one that doesn't: an effective `runAs:'user'` run with **no trigger + user** — a schedule being the canonical case — passed no ObjectQL context at + all, so nothing carried the run id and the run still died on its own + `RECORD_LOCKED`. + + The blocker was never the lock. It was that "no identity" and "no context" were + the same thing on the wire, so a run could not say _who it was_ without also + claiming _what it was allowed to do_. + + **A run with no principal now passes provenance alone.** + `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`, + no `permissions`, not even `isSystem: false`. Every principal gate keys on one + of those fields (the elevation short-circuit on `isSystem`, the ADR-0103 + engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`, + the empty-principal fall-open on all three), so this context authorizes + **identically to no context at all**. The run keeps the documented #1888 + unscoped posture, its loud `[runAs]` warning, and the + `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch + changed — only that it can now be attributed. + + **Provenance moved out of the hook session, into `ctx.provenance`.** `session` + answers _who is calling_ and is absent when no identity envelope was supplied — + a distinction real gates depend on (the attachment access gate skips bare-kernel + writes on exactly that test). Folding a run id into `session` would have forced + an identity-less run to present an empty session, silently turning "no caller" + into "an anonymous caller" and narrowing the #1888 fail-open for attachments + alone. `HookContext.provenance.flowRunId` says what produced the write; the + approvals lock reads it there. + + Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope + (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time + defaults, which made them _required_ on a caller-supplied option and asserted + something untrue — that every data-engine context carries a principal. Callers + have always passed slices (`{ isSystem: true }` for a system read); the type now + says so. + + Migration: nothing to change unless you read the run id inside a hook. If you + wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the + field never shipped under the old name. + +- fb90784: fix(approvals): the status mirror names the human who caused the transition (#3783) + + When an approval moves, the service writes the new status onto the business + record (`approvalStatusField`). That write is what fires the record-change flows + bound to that object — so it is the seam "when the invoice is approved, do X" + runs through. It presented a bare `{ isSystem: true }` context with **no + `userId`**, at six call sites that each know exactly who acted: a submitter + submitting, an approver approving, rejecting, sending back, recalling. + + Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger + user touch data — that identity gap made the most natural approvals automation + there is unwritable in its obvious form. The cascade inherited no user, so its + data nodes were refused, and the author's only way forward was to declare + `runAs: 'system'` and take blanket elevation for a case where a perfectly good + scoped identity existed at the call site all along. + + The mirror now carries the acting user. It stays `isSystem` — the record is + normally locked while its approval is live, so only a platform write can land the + status — because elevation and anonymity are separate choices, and this write + only ever needed the first. Cascades now run as the deciding user with RLS + enforced. + + - **The identity is the authenticated principal, never the request body's + `actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`) + and is only checked against the pending approver slate, never against the + caller. That is tolerable on an audit row; promoting it to the identity of an + RLS-scoped write would have turned a mislabelled audit trail into identity + spoofing. + - **Approval-by-email-link is attributed too.** ADR-0043 action links carry no + session, so they used to decide as pure system. The single-use hashed token + binds exactly one approver and is re-checked against the live slate at + redemption — that is an authentication — so the redeemed decision now presents + that approver, and an emailed approval cascades identically to one made in the + UI. + - **The two machine-driven transitions stay user-less on purpose**: the SLA + escalation's auto-decision and the dead-run sweep. `system:sla` and + `system:dead-run` are reserved audit actors, not users, and presenting one as a + user would put a non-user in `updated_by` and in every downstream flow's + identity. A flow that wants to react to those declares `runAs:'system'` — the + honest answer, and now a deliberate one rather than an artefact. + - **Attribution only — the write is not newly org-scoped.** On an + ExecutionContext `tenantId` is a driver-scoping knob, not attribution + (ObjectQL turns it into a tenant predicate), so passing the request's org would + have silently no-op'd the mirror on a record whose org differs. The automation + engine already back-fills a run's `tenantId` from the resolved user's grants. + + **Visible change:** the mirrored record's `updated_by` now names the acting user + instead of retaining its previous value — ObjectQL's audit stamping is gated on + the write context's `userId` alone, and `isSystem` buys no exemption. That is the + attribution this fix is for: the approver who set the record to `approved` is now + its last modifier. + +- 9dcc0ae: fix(automation): array-form flow `triggerType` fails loudly instead of silently never firing (#3481) + + An array `triggerType` on a flow start node — the shape an author (or an AI + authoring pass) naturally reaches for to fire on more than one event, e.g. + + ```ts + config: { objectName: 'app_task', triggerType: ['record-after-create', 'record-after-delete'] } + ``` + + was accepted everywhere and armed nowhere. Multi-event unions are deliberately + unsupported (only the single tokens plus the `record-after-write` create-OR-update + union exist — see #3457), but nothing said so: `defineFlow` passed the array + (start-node `config` is an open record), the engine's `typeof === 'string'` check + folded it to no trigger and misclassified the flow as **manual**, so it never + entered the trigger-binding audit, and the flow-trigger-readiness lint used the + same `typeof` narrowing and produced no finding. The flow bound to nothing and + never fired, with zero output at any layer — the same silent-never-fire class as + #3427 / #3472, and the last authoring shape still slipping past every guard. + + This is a **defensive** fix — arrays remain unsupported; they now fail loudly: + + - **lint** (`validate-flow-trigger-readiness`): an array `triggerType` containing + any `record-*` element now yields a `flow-trigger-unknown-event` warning at + `os validate` time, steering to `record-after-write` (for created-or-updated) or + one flow per event. + - **engine** (`resolveTriggerBinding`): such an array is routed to the + `record_change` trigger — exactly as an unmappable single token is — instead of + being folded to a manual flow, so it reaches the trigger's bind-time rejection. + - **trigger** (`record-change`): the bind-time rejection detects the array shape + and emits a targeted warning (naming the flow, pointing at `record-after-write` + and #3457) rather than the generic unknown-token line. + +- 7ef20d0: feat(cli,automation): catch `label: 'error'` written where `type: 'fault'` was meant (#3863) + + Two of the three items left open on #3863. Both are about making the fault-edge + contract legible; neither changes routing behaviour. + + **New lint — `flow-error-label-not-fault`.** `type: 'fault'` is what routes a + failure; `label` is cosmetic on an ordinary edge. So this, which reads exactly + like error handling: + + ```ts + { source: 'charge_card', target: 'flag_for_review', label: 'error' } + ``` + + is an ordinary out-edge — and `traverseNext` runs every unconditional out-edge + in parallel. The handler fires on every **successful** run of `charge_card`, + concurrently with the real success path, and never on a failure. The run still + aborts when the node fails. + + Silent in both directions: the author believes failures are handled, and never + notices the handler running when nothing went wrong. The reading is especially + natural for an AI author, since the label is precisely what the intent sounds + like — which is why this is worth a build-time diagnostic rather than leaving it + to a puzzled look at a run trace. + + Deliberately narrow, because a label IS load-bearing on a branching node: a + `decision` / `approval` executor returns a `branchLabel` and traversal then + prefers the edge carrying it. Edges out of those node types are excluded, as are + conditional edges (a guarded path is not the unconditional footgun) and edges + already typed `fault`. Matches the obvious synonyms (`error`, `failure`, + `catch`, `on_error`, …) case-insensitively. Verified against the shipped + showcase: no findings. + + An alias — accepting `label: 'error'` as if it were `type: 'fault'` — was + considered and rejected: two spellings for one concept is harder to read than + one spelling plus a diagnostic that names the fix. + + **Pinned: a handled failure does not consume a flow-level retry.** The two + recovery mechanisms have different scopes and must not compound — a `fault` edge + handles one node, while `errorHandling.retry` replays the flow **from the + start**, re-running every node that already succeeded (a second notification, a + second created record). A failure a fault edge handled is not a flow failure, so + it does not consume a retry. That already held by construction (a routed failure + never propagates out of `executeNode`); it is now a test, so a refactor of the + catch path cannot quietly change it. + + Docs and the automation skill gain both points, plus a note on the edge-property + table that `label` does not select a path except on a branching node. + +- 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582) + + Filter values travel as JSON, so a time- or user-scoped slice writes a + placeholder instead of code: + + ```ts + filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' } + ``` + + The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`, + `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it + (#3574). What was missing is the half that _substitutes a value_: **nothing on + the server ever did**. A placeholder reached the driver as the literal string + `'{current_year_start}'`, compared as text, and matched nothing. + + That failure is invisible — an empty widget looks exactly like a metric that is + legitimately zero — so apps worked around it by computing dates at module load, + which freezes "this year" into the built artifact and quietly goes stale. + + **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two + server-side seams every filter passes through: + + - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST + queries, related lists, saved-view filters and flow `find_records` all resolve. + It runs before the middleware chain, so only author-supplied filters are + inspected; RLS/sharing filters are injected downstream from concrete values. + - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's + `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s. + This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds + comparands directly, so a dashboard widget never passes through `engine.find()`. + + Behavioural notes: + + - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for + `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's + on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so + there is still exactly one source of truth for the storage convention. + - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned + per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair + can never straddle a boundary. + - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads + `userId`. A request carrying neither now **throws** instead of resolving to + `null` — a null comparand degrades to `IS NULL` on most drivers and would hand + back the rows the filter was written to exclude. + - An unrecognised placeholder **throws**, carrying the near-miss fix + (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` → + `{current_quarter_start}`). This matches what `objectstack build` already + enforces. Consequence, previously implicit and now load-bearing: a filter value + that is _entirely_ `{...}` is always read as a placeholder, so a literal value + of that shape is not expressible — rename the value. + + Also in this change: `notify` no longer sends the six-character string + `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks + `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)` + turned that into a phantom recipient — the emit "succeeded", addressed nobody, + and said nothing. Unresolved recipients are now dropped, and a node with no + recipient left fails naming the offending template and pointing at the start + node's `config.expand` (#3475), which does hydrate the relation. + +- c88eeda: fix(automation): flow string templates serialize object tokens readably, never `[object Object]` (#3450) + + A flow string field that embeds an object-valued token — most notably the + engine's `$error` (`{nodeId, message, ...}`, set on a failed step) in a fault + handler's notify body — rendered as the useless `[object Object]`. The + multi-token branch of `interpolateString` coerced every value with `String()`, + and `notify-node` did the same for a sole `{$error}` token. + + - New shared `stringifyForTemplate` helper (`builtin/template.ts`): objects and + arrays are JSON-serialized (so the text stays legible and still carries the + message), primitives pass through, `null`/`undefined` render as ''. + - `interpolateString`'s embedded-substitution branch and `notify-node`'s + title/body coercion use it. The sole-token branch still returns the raw value + (typed config fields keep their type), and `{$error.message}` still resolves + to just the message string — the documented, cleanest author form. + + Split from #3425 (the readonly-strip half shipped in #3465). + +- 5602211: fix(automation): close the default-routable footgun on refuse-to-execute guards (#3863) + + #3881 stopped a `fault` edge from swallowing a guard refusal, keyed on + `NodeExecutionResult.errorClass`. That field defaults to `'runtime'`, which was + right for compatibility — every executor written before the split keeps its + routing — but it leaves the footgun pointing the other way: **a new guard is + routable unless its author remembers to classify it**, and forgetting is silent. + Nothing in the type system catches it. + + Three changes close that for the guards that exist and make the next one hard to + get wrong. + + **`refuseNode(reason)`** — one call that returns a guard-class failure, so + "write a guard" and "mark it un-routable" become the same act. Its doc states + the test for using it: re-running unchanged can never succeed AND the fix is to + edit metadata. It also states the inverse, because over-marking is not the safe + direction — classifying a handleable condition as `guard` turns a recoverable + integration into a dead run. + + **Five guards that were never marked** are now un-routable. All are missing + required config or a defective graph, none can succeed on a retry: + + - `http` with no `url` + - `subflow` with no `config.flowName`, and `subflow` exceeding max nesting depth + (a recursive graph nests exactly as deep next run) + - `map` with no `config.flowName` + - `connector_action` with no `connectorId` / `actionId` + + The seven `crud-nodes` guards from #3881 move to the helper — same behaviour, + one spelling. + + **A behavioural inventory test** drives every known guard through the engine + with a fault edge attached and asserts it is still fatal, matching on the + refusal text so a guard failing for a different reason cannot pass vacuously. + Verified to have teeth: un-marking one guard fails its row immediately. The + negative half is pinned too — a plain node failure and a thrown error must still + route, since that is what fault edges are for. + + Deliberately **not** marked, and why: a degraded connector (#3017 says recovery + is automatic), a collection that did not resolve to an array, a collection over + the iteration cap, and a subflow that failed on its own. Those are conditions + the world caused, and an author must be able to handle them. + + Considered and rejected: making `errorClass` required on the result type. It + would enforce classification at compile time, but it breaks every node executor + returning a failure — 281 call sites across the repo plus third-party + executors — for a type-only gain over the helper. + +- 9bf4588: fix(service-automation): bind `previous` (as null) on the create leg so start conditions can discriminate create vs update (#3427) + + The engine bound `previous` into the flow condition scope only when it was + truthy, so on a record insert (`record-after-create`, and the create leg of + `record-after-write`) `previous` was an **unknown** CEL variable. Any reference to + it — including the documented `previous == null` create-discrimination — threw + `condition failed to evaluate as CEL: Unknown variable: previous`, failing the + whole start condition and dropping the run. + + `previous` is now always bound, to `null` when there is no prior row. So + `previous == null` is the create leg and `previous != null` / `previous.` + the update leg — the pattern the `record-after-write` docs and the Studio flow + designer advertise. Update-triggered flows are unaffected (`previous` was, and + stays, the prior row there). + +- 70a1ce1: fix(automation): the resume gate follows `map:` too, and the route stops accepting engine-internal variables (#3853) + + Two holes in the #3801 resume gate, both demonstrated with a repro. + + **1. The chain walk missed `map:`.** `resumeInternal` handles the two linked-run + correlations oppositely — a `subflow:` pause _delegates_ the signal to the child, + a `map:` pause _re-runs_ the map node — and the gate followed only the first. So + a run parked on a `map` node was judged on `map` itself (`resumeAuthority: 'any'`) + and let through even while the item it was waiting on sat on an `approval`. + + `map` is the batch-approval shape, and the map parent's run id is the one a + launcher holds. Since `$mapState.started` is advanced past the in-flight item + before the suspend, an empty-body resume of the parent **skipped that item's + approval outright**, orphaning its still-pending request; a later real decision + then bubbled into a parent already waiting on the next item, cascading the + misalignment. + + The walk now follows both prefixes: a linked-run pause is waiting on a CHILD, so + the child's node carries the authority — the gate reads _the item, not the loop_. + + **2. Resume `inputs` could write the engine's `$` namespace.** They are applied + as bare flow variables, so a caller could set the exact handoff keys the engine's + map bubble uses (`.$mapItemDone` / `$mapItemOutput`) and have the map + record a per-item result for a decision nobody made — the node id is readable + from `GET /automation/:name`. The same reached `$runId`, which `approval` / + `wait` nodes use to correlate external state back to a run. + + `POST /automation/:name/runs/:runId/resume` now answers **400** when `inputs` + names anything in the engine namespace (`$…`, or a `.$` segment). Enforced at the + transport, not in the engine, so the in-process bubble keeps working — the same + trust split the gate itself uses. + + Nothing changes for author-declared variables: `{ new_assignee: 'ada' }` and + dotted names like `collect.note` are unaffected. If you were driving a batch- + approval `map` by resuming the map's own run id, resume the **item's** run + through its owning service instead (e.g. `client.approvals.approve`) — the map + advances itself when the item completes. + +- 93f267f: fix(automation): one chokepoint for the resume signal — `output` reopened the hole `inputs` had just closed (#3879) + + #3853 guarded `signal.variables` at the route. That closed one of **two** + equivalent paths into the same variable map and left the other open: + `signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run + parked on a `map` node `run.nodeId` **is** the map node — so + + ```jsonc + { + "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } + } + ``` + + writes exactly the `.$mapItemDone` the `inputs` guard had refused, + making the map record a result for an item nobody decided. Demonstrated with a + repro, then fixed. + + Scope: the #3853 map gate still held, so a batch whose pending item sits on an + `approval` was refused before any of this — the **approval bypass stayed + closed**. The residual was forging the recorded result of an item on an + _ungated_ pause. + + Two escapes with one shape is a design signal, not two bugs, so the fix is + structural rather than a third patch: + + - **`applyResumeSignal` is the one place a resume signal reaches the variable + map.** Both fields are collected into a single write list (already in final, + prefixed form), checked, then applied — a new signal field is covered by + construction rather than by remembering. + - **All-or-nothing**, and checked _before_ the suspension is consumed: a + rejected signal applies nothing (not even legitimate keys sent alongside) and + the run stays parked, so the real continuation still lands. + - **The engine owns the rule; the transport maps the verdict.** `resume` returns + `{ success: false, code: 'invalid_signal' }`; the route answers **400**. The + SDK and any future adapter inherit it — implemented in one transport it + protected exactly one transport, and one field of it. + - Engine-built signals (the subflow output mapping, the map item handoff) are + exempt via a module-private symbol. Deliberately _not_ + `RESUME_AUTHORITY_SERVICE`: that marker means "the owning service authorized + this decision", and a service still has no business writing engine internals. + + `AutomationResult.code` gains `'invalid_signal'` alongside `'forbidden'` — a + `switch` over it needs a new arm; a plain read does not. + + Nothing changes for authoring: ordinary variables pass, `$` mid-name (`price$`) + and dotted names (`collect.note`) included. Only names the engine reserves — + `$…` or a `.$` segment — are refused. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/formula@17.0.0-rc.0 + ## 16.1.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index bbb26b831d..2238c19a55 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "16.1.0", + "version": "17.0.0-rc.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 e47021c9de..9ee1f757a5 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,129 @@ # @objectstack/service-cache +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index f103b9cb7d..f7c1b0897d 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "16.1.0", + "version": "17.0.0-rc.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 e347846f9f..34f4a6bd5e 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-cluster-redis +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/service-cluster@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 962b91d21a..d232d2b61e 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": "16.1.0", + "version": "17.0.0-rc.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 d5ce2fb44f..c9db140eee 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-cluster +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index fae0e0b0d0..0e7ab190f5 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "16.1.0", + "version": "17.0.0-rc.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 930ea76d7b..ed002a7316 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,350 @@ # @objectstack/service-external-datasource +## 17.0.0-rc.0 + +### Minor Changes + +- 48c110e: feat(datasource): a datasource that is down is visible, and says why when queried (#3827, #3828) + + #3816 made an explicitly-bound datasource that cannot connect refuse the boot. Two + gaps survived that fix, both in the cases that still boot — a policy denial, an + `autoConnect` datasource, or any failure the operator waved through with + `OS_ALLOW_DRIVER_CONNECT_FAILURE`: + + - **It was invisible.** `DatasourceSummary.status` was the literal `'unvalidated'` + for every row — the contract declared three states and the implementation only + ever emitted one — so a dead datasource looked exactly like a healthy-untested + one. `checkDriversHealth()` could not help either: it iterates registered + drivers, and a datasource that never connected was never registered, so it is + _absent_ from the probe rather than unhealthy. The only trace was a warning + that scrolled past at boot, which made the diagnostic procedure "restart the + server and re-read the logs". + - **The query-time error said nothing.** `getDriver()` answered four different + situations with one sentence, `Datasource 'x' is not registered.`: refused by + policy, failed to connect under the escape hatch, a misspelled name, and + `active: false`. Only the third is an authoring bug, so the other three sent + the reader hunting for a typo that does not exist. + + Both come from the same root: `connect()` already produced a `ConnectResult` for + every attempt and every caller threw it away. + + - **`DatasourceConnectionService` retains the last verdict per datasource**, with a + coarse `availability` (`available` / `blocked` / `failed` / `unattempted`) beside + the raw status. New `getConnectionState(name)` / `listConnectionStates()`. + `disconnect()` drops it, so a removed pool stops explaining itself. + - **`DatasourceSummary.status` tells the truth**: `ok` | `error` | `blocked` | + `unvalidated`, with a new operator-facing `statusReason`. `blocked` is new and + deliberate — a policy denial is a decision, not a fault, and will not clear on + its own. Reported in **Setup → Datasources**, `GET /api/v1/datasources`, and the + summary returned from create/update, so a "Save" whose pool failed to open is no + longer presented as success. + - **`ERR_DATASOURCE_UNAVAILABLE` (HTTP 503)**: new `DatasourceUnavailableError` + from `@objectstack/objectql`, thrown by `getDriver()` when the connection layer + recorded _why_ a declared datasource has no driver. An undeclared name keeps the + original message — there is genuinely nothing to add. 503 rather than 500/400: + nothing about the request is wrong, and the state may clear. + - **A privileged/public split for the reason.** The error **never** carries the + underlying cause — connect failures routinely contain hosts, ports and DSNs, and + a policy's `reason` is written for operators. Those stay in the logs and the + (admin-gated) datasource list. `DatasourceConnectDecision` gains an opt-in + `publicReason` for hosts that want to tell tenants something specific + (e.g. `'External datasources require the Scale plan.'`); it is the only string + that reaches an end user. + - **Readiness is deliberately not gated on this.** `/ready` still reflects + registered-driver health only: an optional datasource being down must not pull an + otherwise-working replica out of the load balancer. + + Also lands a drift guard for **#3826**, and corrects ADR-0062's status while doing + it. The ADR claimed D1 ("exactly one definition → live driver path") as + implemented; only the _construction_ half converged. The `default` driver is still + registered as a `driver.*` kernel service and connected by `ObjectQLEngine.init()`, + with its own failure verdict, pool teardown, and no connect policy. What blocks the + merge is an input-shape mismatch, not ordering: `connect()` takes a datasource + _definition_ and builds the driver, while `default` arrives pre-built, and routing + it through the service would make `ObjectQLPlugin`'s boot depend on an optional + higher-layer service. Until that is designed, `degraded-boot-parity.test.ts` pins + both paths to the same operator-visible contract (fail-fast by default, identical + `OS_ALLOW_DRIVER_CONNECT_FAILURE` parsing, `DEGRADED BOOT` on stderr) so a change + to one that forgets the other fails CI — #3741 → #3758 was exactly that miss, and + it cost three months and a second bug report. + + **Migration.** Additive. `DatasourceSummary.status` gains a `'blocked'` member: a + consumer exhaustively switching on it needs a case (the admin UI shows it as a + distinct state). Nothing that was `'ok'` or `'error'` changes meaning; rows that + were reported `'unvalidated'` now report their real state. Query-time errors for a + datasource the connection layer recorded change from a generic `Error` to + `DatasourceUnavailableError` (503 instead of the previous catch-all status); + matching on the old `is not registered` text still works for the undeclared-name + case, which is the only one that was ever accurate. + +- 87aca93: fix(datasource)!: a declared datasource that objects bind to must connect, or the boot fails (#3758) + + `DatasourceConnectionService.handleFailure()` fail-fasted only for an `external` + datasource with `validation.onMismatch: 'fail'`. Everything else degraded to one + `warn` line — including the case the D2 auto-connect gate itself flags as having + **no fallback path**: a datasource that objects bind to explicitly via + `object.datasource`. Those objects never fall through to the `default` driver; + `engine.getDriver` throws `Datasource 'x' is not registered` for them. + + So an app declaring `datasource: 'analytics'` with 20 objects bound to it, booted + against a wrong `ANALYTICS_URL`, started clean and exited zero — and then failed + every read and write of those 20 objects with an error that reads nothing like + _the analytics database is unreachable_. The rest of the app worked, which made it + **harder** to locate than a total outage: it looks like "some pages are broken", + not like a misconfigured datasource. This is the same decision #3741/#3751 fixed + one layer up in `ObjectQLEngine.init()`; the boundary here was still drawn in the + old place. + + - **Fail-fast is now keyed on "no fallback path", not on `onMismatch` alone.** At + the `declared-auto` (boot) trigger, a connect failure aborts the boot when the + datasource is `external` + `onMismatch: 'fail'` **or** when ≥1 object binds to + it explicitly. `autoConnect: true` with nothing bound stays lenient — that is + "connect it if you can", and nothing declares a dependency on it. The + runtime-admin create/update and boot-rehydration triggers are unchanged and + still always degrade: a UI action must never brick a running server. + - **Every failure mode counts**, not just an unreachable socket: an unresolvable + `external.credentialsRef` (D3) and an unsupported `driver` leave the bound + objects exactly as dead, so they take the same verdict. + - **The error names the bound objects** (up to 10, then `+N more`) alongside the + underlying cause, so the message points at the real problem instead of just the + datasource name. The service already receives the list for post-connect + `syncObjectSchema`. + - **`connectDeclared()` attempts every gated datasource before throwing**, and + aggregates, so one failed boot reports all the misconfigured ones rather than + one per restart — the same shape as `ObjectQLEngine.init()`'s + `DriverConnectError`. + - **The escape hatch is shared with the engine guard**: + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` now also covers this path (and covers + `onMismatch: 'fail'`, which previously had no opt-out). The operator intent is + identical — "I know the database is unreachable, boot anyway" — and two flags + would only guarantee one of them gets missed. When set, boot continues and a + `DEGRADED BOOT` banner goes to stderr as well as the logger, because `os serve` + swallows stdout during boot. `emitDegradedBootBanner` moved to + `@objectstack/types` so both call sites share one implementation; + `@objectstack/objectql` re-exports it unchanged. + + ADR-0062 D5 is amended with the new criterion and the shared flag. + + **Migration.** No change for a correctly configured deployment — a datasource that + connected before still connects. A deployment that was _silently_ booting with a + dead, explicitly-bound datasource now fails the boot instead, naming the + datasource, the cause, and the objects that depend on it; fix the datasource + configuration. To keep booting without it — deliberately, knowing every request + touching those objects will fail — set `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +- 19e3e6e: feat(runtime)!: the standalone `default` datasource is a declaration, connected through the one datasource path (#3826) + + ADR-0062 D1 asked for exactly one "definition → live driver" path. Construction + converged earlier; the _connect + failure verdict_ half did not — the standalone + `default` driver was pre-built and smuggled into the engine as a `driver.*` + kernel service, so "what if it cannot connect" lived in `ObjectQLEngine.init()`, + a second implementation of the policy `DatasourceConnectionService` owns for + every other datasource. #3741 → #3758 showed what two copies cost: a fix to one + missed the other for three months. + + - **`createStandaloneStack` now emits a datasource DEFINITION**, not a driver. + URL→config translation and `mkdir` stay host concerns; the new + **`DefaultDatasourcePlugin`** (exported from `@objectstack/runtime`) connects + the definition at boot through the shared `DatasourceConnectionService` — + same driver factory, same failure verdict, same retained state. It must be + registered before `ObjectQLPlugin` (boot schema-sync needs the driver); + `createStandaloneStack` orders it correctly. + - **`sqlite-wasm` joined the shared driver factory** (`sqlite-wasm` / + `wasm-sqlite` ids) — it was the last bespoke construction site. + - **`bootCritical` on `ConnectableDatasource`**: the host declares a datasource + the platform cannot run without; a boot connect failure is then fatal + regardless of object bindings, sharing `OS_ALLOW_DRIVER_CONNECT_FAILURE` and + the `DEGRADED BOOT` banner with the engine-level guard. A connect policy that + denies a boot-critical datasource fails the boot loudly — the #3828 "denial is + not a failure" boundary was drawn for optional datasources. + - **`connect(record, { asDefault: true })`**: registers the built driver as the + engine's default under its natural name (no `'default'` stamping — routing to + `default` goes through the engine's default-driver fallback, and the natural + name keeps logs/lookups byte-for-byte with the previous boot). + - **`default` is a host-reserved name**: an app bundle declaring a datasource + named `default` is rejected at load (`AppPlugin`), and the runtime-admin + create rejects it too. It would shadow the host's primary datasource and, if + it passed the auto-connect gate, silently divert every unbound object. + - The primary DB now shows a REAL `status` in Setup → Datasources (#3827) — + `ok` when connected, `error` + reason when the operator boots degraded. + - `ObjectQLEngine.init()` is unchanged and keeps its fail-fast: it re-connects + the already-connected default (every open-core driver's `connect()` is + idempotent), which is exactly the boot verification #3741 wants. + - `DriverPlugin` remains the escape hatch for tests and pre-built/proxy drivers + (e.g. the CLI's `telemetry` datasource) — no longer how the standalone + default boots. The CLI serve config-load fallback (`createStorageDriver`, + incl. mysql/turso) still constructs directly; tracked in #3826. + + **Migration.** Boots through `createStandaloneStack` (CLI `serve`/`dev` + artifact path, quickstarts, embedders using the stack factory) change shape but + not behavior: same driver kinds, same URLs, same fail-fast semantics, same + escape hatch. Embedders that composed `DriverPlugin` manually are unaffected. + An app that declared a datasource literally named `default` now fails to load + with a rename instruction — that name never routed correctly to begin with. + +- 5cfd4d5: feat(cli): the serve storage fallback declares the default datasource instead of constructing a driver (#3826) + + The last open-core second site of "definition → live driver": when a host + `objectstack.config.ts` supplies objects but no driver plugin, `serve` built a + driver via `createStorageDriver` and registered it through `DriverPlugin`, with + its connect and failure verdict landing in `ObjectQLEngine.init()` — the same + split #3869 removed from the standalone stack. + + - **`createStorageDriver` is gone.** `resolveStorageDefinition` translates the + driver kind + URL into `{ driverId, config }` (a pure host-side translation, + like `standalone-stack`'s), and serve hands it to the runtime's + `DefaultDatasourcePlugin` — same shared factory, same `bootCritical` failure + verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` escape hatch, and the primary + DB's real status in Setup → Datasources. + - **`mysql`/`mysql2` joined the shared driver factory** (SqlDriver over + `mysql2`; DSN or discrete fields, secret as password). + - **Host-composition passthroughs**: the factory honours `config.autoMigrate` + (the #2186 dev loosen-only self-heal, for the SQL kinds) and `config.persist` + (the CLI's wasm `on-disconnect` mode). Connection builders ignore both keys. + - **`turso`/libSQL fails loud at resolution**, same typed + `UnsupportedDriverError`, same actionable message — nothing is constructed to + fail later. + - **The `telemetry` sibling datasource stays a pre-built `DriverPlugin`** — the + documented escape hatch for named auxiliary drivers. Its provisioning now + gates on the statically-known sqlite file path; the old coupling to the + primary's _resolved_ engine is replaced by the telemetry provision's own + step-down check, which already guarded the ABI-broken case. + + Verified end to end: a host-composed config (plugins + objects, no driver) + boots through the declared fallback with the same banner labels; the artifact + path (`dev:crm --fresh`) is table-for-table unchanged (71 tables, zero + `no such table`). + + **Migration.** None for CLI users — same URLs, same env vars, same banner. The + removed `createStorageDriver` was CLI-internal; `resolveDriverType`, + `inferDriverTypeFromUrl` and `UnsupportedDriverError` are unchanged. + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index f5b5aa4ed1..4c14b5dba3 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "16.1.0", + "version": "17.0.0-rc.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 cf8873c3ab..0f1709e809 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,459 @@ # @objectstack/service-i18n +## 17.0.0-rc.0 + +### Minor Changes + +- 4cca74c: fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778) + + A translation authored in the product saved successfully and then rendered + nothing. Not a resolver gap — a contract split. The `translation` metadata type + (`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it) + was registered against `AppTranslationBundleSchema`, an object-first shape keyed + on `o.`. Every resolver, `os i18n extract`, `os i18n check`, the objectui + hooks, and all nine shipped bundles read `objects.`. Nothing bridged the + two, so the save path and the read path never met. + + **Why converge instead of bridge.** A converter was the obvious fix and the + wrong one: it would be throwaway code, and it would start producing _working_ + `o.`-shaped rows — closing the migration-free window that exists precisely + because the feature never functioned. The retired shape's real-world footprint + was zero: all three `*.translation.ts` files in the tree (platform-objects, + CRM and todo examples) were already `objects.`-shaped, contradicting the type's + own registered schema. Converging is a registration fix, not a migration. + + **Breaking.** `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema`, and + their types are **deleted** — no deprecation cycle. Nothing worked end-to-end + through them, so there is no functioning consumer to protect, and a + deprecated-but-present schema is exactly the exemplar an AI agent copies into + new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go + with them: zero implementers, so they advertised a capability the runtime never + delivered. + + **The replacement.** `TranslationItemSchema` — one locale of the same + `TranslationData` groups a file bundle uses, plus the `locale` it translates, + with a `defineTranslation()` factory. An item is one entry of a + `TranslationBundle`; that is the whole type. + + Three details are deliberate, all aimed at the failure being silent rather than + loud: + + - **`locale` is required**, not inferred from the item name. The sync skips an + item whose locale it cannot resolve, and a skip is invisible to whoever — or + whatever — authored it. (The name fallback still covers rows written before + this.) + - **Retired keys are rejected, not stripped.** Zod drops undeclared keys + silently, which would reproduce this bug exactly: save succeeds, nothing + renders. A pre-parse guard turns that silence into a 422 naming the group to + use (`'o' … — use 'objects.'`). It runs ahead of the parse so the + retired keys stay out of the schema itself — the generated JSON Schema and the + Studio editor never advertise a shape that cannot work. + - **`ObjectTranslationData.label` is now optional.** Partial translation is the + normal state and every resolver already treats each key as independent. + Requiring it forced authors to restate the source label just to validate, + filling bundles with fake translations that mask real coverage gaps. + + Also in this change: the authored-translation sync warns (naming the row and the + fix) when it meets a row still in the retired shape instead of loading it into + nowhere, and no longer merges publish bookkeeping (`_lockReason`, + `_packageVersion`, …) into the translation layer. `GET +/i18n/labels/:object/:locale`'s fallback now reads the nested + `objects..fields..label` data it is actually given — it scanned for + flat dotted `o..fields.` keys, a third dialect no producer ever + wrote, so it always returned `{}`. + + Migration: author every translation — file or runtime item — under `objects.`. + `o` → `objects`, `app` → `apps`, `nav` → `apps..navigation..label`, + `dashboard` → `dashboards`, `_globalOptions` → + `objects..fields..options`, `_meta.locale` → top-level `locale`, + `_actions.confirmMessage` → `_actions.confirmText`. `reports`, `notifications`, + `errors`, and `namespace` had no runtime consumer and have no replacement. + +### Patch Changes + +- 1d4756e: fix(i18n)!: `/i18n/labels/:object/:locale` emits the entry shape it declares — + and stops discarding `help`/`options` (#3847) + + `GetFieldLabelsResponseSchema` has always declared each label as an object: + + ```ts + labels: z.record( + z.string(), + z.object({ + label: z.string(), + help: z.string().optional(), + options: z.record(z.string(), z.string()).optional(), + }) + ); + ``` + + Both serving surfaces emitted `Record` — a bare label per field. + A client typed against `GetFieldLabelsResponse` read `labels[field].label` and + got `undefined`, because the value was the string itself. The SDK's type was + right the whole time; the servers were wrong. + + The cost is not only the type mismatch. `FieldTranslationSchema` carries `help` + and `options`, bundles populate them, and the endpoint threw them away. objectui + needs exactly those — its `spec-translations.ts` transform reads `label` **and** + `options` (as `fieldOptions...`) — and gets them by pulling the + whole bundle from `/i18n/translations/:locale` and resolving client-side. The + per-object endpoint could not have served it even if it wanted to: the data was + being dropped at the emit site. + + Fixed at that emit site, `resolveObjectFieldLabels`, which both surfaces already + share as of #3833 — so one change covers both. `help` and `options` are attached + only when non-empty: an `options: {}` would claim a field has translated options + and hand back none, and a `help: ''` would erase a caller's source help text. + Fields with no non-empty `label` are still omitted entirely, which is what lets + `ResolvedFieldLabel.label` be a required string. + + **The response schema is unchanged** — this moves the implementation onto the + contract, not the contract onto the implementation. Generated docs are + byte-identical for that reason. + + `placeholder` is deliberately left out. `FieldTranslationSchema` has it and the + response schema does not, so emitting it would be widening the contract rather + than satisfying it — and adding an optional response field later is additive and + non-breaking, whereas guessing now is not. + + The regression guard is the part worth keeping: a test that builds the response + body from the shared helper and parses it with `GetFieldLabelsResponseSchema`. + Nothing had ever put the emitted value and the declared contract in one + assertion, which is precisely why a bare string could sit under an object schema + unnoticed. Third and last of the declared ≠ enforced gaps on this endpoint + family, after #3676 (request filters no server read) and #3833 (a derivation + scanning a retired dialect). + + BREAKING: `labels[field]` is now `{ label, help?, options? }` rather than a + string. No consumer in this repo or objectui read it — objectui never calls this + route, and in-repo use is the SDK method plus URL-shape tests — so the practical + blast radius is nil, and this is the cheap moment to align it. + +- 720c5ad: fix(runtime,i18n): the dispatcher's field-labels route reads the bundle shape + producers actually write — one shared derivation (#3833) + + `GET /i18n/labels/:object/:locale` served through the dispatcher returned + `{ labels: {} }` for every provider. Its derivation scanned for flat + `o..fields.` keys: + + ```ts + const prefix = `o.${objectName}.fields.`; + for (const [key, value] of Object.entries(translations)) { … } + ``` + + That dialect was retired by #3778 — no producer has ever written it, and a real + bundle's top-level keys are the `TranslationData` groups (`objects`, `apps`, + `messages`, …), so the prefix could not match anything. 4cca74c fixed the + identical derivation in `service-i18n` and did not reach the dispatcher's copy. + + This is not a rare fallback. `getFieldLabels` is optional on `II18nService` and + **nothing implements it** — not `memory-i18n`, not `file-i18n-adapter` — so the + dedicated-method branch both surfaces check first is dead in production and this + derivation is the only path there is. Any stack served by the dispatcher (the + AppPlugin in-memory provider auto-registered for stacks declaring translation + bundles) got an empty map, indistinguishable from "this object has no translated + labels": nothing errored, nothing warned. + + Worse than the class it was found next to. #3676, which prompted the check, + ignored a declared filter and returned the full bundle — a correct superset. This + returned nothing and said it was fine. + + The derivation now lives once, as `resolveObjectFieldLabels` in + `packages/spec/src/system/i18n-resolver.ts`, alongside the other resolvers that + read `TranslationData`. Both surfaces call it. Keeping a copy each is precisely + how one got fixed and the other did not; the next bundle-shape change now has one + place to land. Fields carrying no non-empty `label` stay omitted rather than + emitted blank — partial translation is the normal state, and callers merge this + map over their source labels, where a `''` would erase them. + + ### The tests were fiction on both sides + + The dispatcher's fallback test fed flat `o.contact.fields.first_name` keys and + asserted labels came back, so it passed on data that cannot occur while + production returned `{}` — the same failure mode as the client test retired in + #3676, which asserted a query string was built that no server read. It now feeds + the nested shape, and was confirmed to fail against the pre-fix code (`expected +{} to deeply equal { first_name: 'First Name', … }`) rather than merely passing + after it. The shared helper carries its own unit tests, including one pinning + that the retired flat dialect resolves to `{}`. + + The same suite's mock also declared a `getFieldLabels` no shipped provider has, + and returned flat-dialect data from `getTranslations`; both now reflect what a + real provider does, with the divergence noted where it remains deliberate. + + Not addressed here, filed separately: `GetFieldLabelsResponseSchema` declares + `labels` as `Record`, but both surfaces emit + `Record` — a third declared ≠ enforced gap in the same endpoint, + and a wire-shape change too breaking to fold into a correctness fix. + +- 41642b0: fix(runtime,i18n)!: `/i18n/locales` answers in one shape — plus the + success-envelope conformance gate that found it + + Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not + match the schema declaring it, and each survived a green suite because **every + test asserted the emitted body against a hand-written literal**. Comparing + output to a literal proves the code does what the test author believed; it + cannot prove the code does what the contract declares. Nothing had ever put the + emitted value and the declared schema in the same assertion. + + This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts` + in `runtime`, the missing success-path twin of service-i18n's + `error-envelope.conformance.test.ts` and the same pairing storage got in #3689. + Every `/i18n` success body is parsed against `BaseResponseSchema` and against + the schema `plugin-rest-api` names for that route (`responseSchema: +'GetLocalesResponseSchema'`, …), imported rather than restated. + + **It found a fourth gap on its first run.** `GET /i18n/locales` passed + `getLocales()`'s raw `string[]` straight through the dispatcher, while + `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and + service-i18n, the _other_ provider of this identical route, already emitted + descriptors. One endpoint, two shapes, decided by which plugin mounted it, with + the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type. + + That is the same split #3833 found in the field-labels derivation, one route + over, and it happened for the same reason: two surfaces, one mapping, kept + twice. So the mapping is now shared as `toLocaleDescriptors` in + `packages/spec/src/system/i18n-resolver.ts`, next to `resolveObjectFieldLabels`, + and both surfaces call it. `label` is the locale code — no display-name source + exists in the tree and the schema requires the field; inventing an ICU + display-name table here would be a product decision, not an implementation + detail. + + The gate was verified the same way #3833's was: the fix was reverted and the + suite confirmed to fail on it — + + ``` + locales body does not match its declared schema: + [{"expected":"object","code":"invalid_type","path":["locales",0], + "message":"Invalid input: expected object, received string"}, …] + ``` + + — rather than merely passing once written. Five existing tests pinned the bare + `string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned + while the shape is owned by the schema. + + BREAKING: `GET /i18n/locales` served by the dispatcher now returns + `[{ code, label, isDefault }]` instead of `['en', …]`. Callers on the + service-i18n mount already received this shape, and the SDK's published + `GetLocalesResponse` type has always described it, so this ends a divergence + rather than starting one. + + Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a + `responseSchema` name on essentially every route (29 declarations across 28 + handlers), so the route → declaring-schema mapping needed to run this check + repo-wide exists today and is unused. + +- f1a8114: fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636) + + Tranche 3 of the #3563 route audit — the last un-audited server surface. The + dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own + package boundary, and two services mount routes outside both: they reach for + the `http-server` service and register straight on `IHttpServer`, so neither + `RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the + SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture: + expressed, working, guarded by nothing. + + **Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and + `i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each + enumerated for real — the registrar runs against a capturing mock + `IHttpServer` and its registration calls _are_ the route set, so a new route + lands with a reviewed disposition or fails CI. The client half is + `packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the + boundary as relative source imports, never a service→client package edge. + + **Two wire-level 404s fixed.** `i18n.getTranslations` sent + `/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent + `/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's + mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts` + contract — mounts only the path form. Neither call could ever be answered. + Both had carried a green `sdk` row in the dispatcher ledger since tranche 1, + because that guard asks whether the client _method_ exists, not whether it + speaks a URL anything mounts. The client now sends the path dialect, the same + resolution #3611 gave `meta.getView`, and a new suite drives the real client + at a real router so a revert cannot pass quietly. + + **One response-shape fix.** service-i18n's success bodies omitted the + `success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK + returned the raw `{ data: … }` wrapper against that provider while returning + the declared unwrapped shape against the dispatcher — one method, two shapes, + decided by which plugin mounted the route. Its three handlers now emit the + `{ success: true, data }` envelope the `i18n` route group declares. `data` did + not move, so direct body readers are unaffected. + + Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the + browser capability URL objectql stamps into file-field payloads, and the two + local-driver loopbacks). The chunked-upload family, flagged for triage, turned + out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero. + + Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites + and mounted by none (#3641); the cross-surface URL conformance guard that would + have caught all of the above mechanically is the capstone (#3642). + +- bd68f08: fix(service-storage,service-i18n): emit the declared error envelope, not a bare `{ error }` (#3675) + + #3636 aligned the **success** bodies of the autonomously-mounted service + routes because those were the ones breaking `ObjectStackClient.unwrapResponse`. + The error bodies were left alone and stayed a bare `{ error: '' }` — + with the code, where one existed at all, as a _sibling_ of `error` rather than + a field of it — against a contract (`BaseResponseSchema` + `ApiErrorSchema`) + that declares `{ success: false, error: { code, message } }`. + + So the same SDK method returned two different error shapes depending on which + provider mounted the route: a caller reading `body.error.message` got the real + message from the dispatcher and `undefined` from these services. All 32 sites + (27 in `storage-routes.ts`, 5 in `i18n-service-plugin.ts`) now go through a + single `sendError` helper per module — the nested-`error` shape the sibling + services already use (`settings-routes.ts`, `share-link-routes.ts`), plus the + `success` flag those two still omit and the contract requires. + + **Codes moved, and that is the breaking part.** `AUTH_REQUIRED`, + `ATTACHMENT_DOWNLOAD_DENIED` and `FILE_DOWNLOAD_DENIED` used to sit at + `body.code`; they now sit at `body.error.code`. The SDK is unaffected — it + already reads `errorBody?.code || errorBody?.error?.code`, one of the four + shapes its error path sniffs for, which is the consumer-side shim Prime + Directive #12 says to cure at the producer. The console's attachment panel + was NOT: it read the top level only, so every gated download would have + degraded from "You don't have access to download this attachment." to + "Download failed (403)". Fixed in objectui to read both dialects, since a + console build ships independently of the server it talks to. + + **Guarded both ways.** New `error-envelope.conformance.test.ts` in each + service drives every distinct error branch through the real registrar and + parses the body against the real `BaseResponseSchema` imported from + `packages/spec` — not a local restatement of it — and scans the module source + so a new route cannot quietly reintroduce the bare shape. The route ledgers + (#3563 → #3656) could never have caught this: they audit which routes exist + and whether the SDK can address them, not what comes back. + + Measured and left alone: the dispatcher does not conform either — it puts the + HTTP status in `error.code`, where the contract declares a semantic string, + and parks the real code in `details` to work around its own occupied field. + That deviation is now pinned to exactly one field by a test in + `http-dispatcher.test.ts` rather than described in prose. Also unchanged: + service-storage's success bodies are still three shapes of their own + (`{ data }`, bare `{ url }`, `{ ok, key }`, none with `success: true`) — a + non-additive change that needs its own issue, not a quiet ride along with this + one. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 62d6a9f1cb..4cf7da2f8c 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "16.1.0", + "version": "17.0.0-rc.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 75df24ca68..57256384b7 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,166 @@ # @objectstack/service-job +## 17.0.0-rc.0 + +### Minor Changes + +- 394b7a1: feat(job): honor the authored `retryPolicy` / `timeout` in the job scheduler (#3494) + + `JobSchema.retryPolicy` and `JobSchema.timeout` used to be parsed-but-ignored + (the 2026-06 liveness audit's aspirational-config cluster). They are now + enforced end to end — built rather than pruned, since retry/backoff and + per-run time limits are semantics job authors reasonably expect: + + - **spec**: `IJobService.schedule` gains an optional 4th `options` argument + (`JobScheduleOptions` with `retryPolicy` / `timeout`, mirroring the + authorable schema); new `JobRetryPolicy` type. Backward compatible — + existing 3-arg implementations and callers are unaffected. + - **service-job**: new `runWithPolicy` helper (exported, with + `JobTimeoutError`) wraps every handler invocation in `CronJobAdapter` and + `IntervalJobAdapter`; `DbJobAdapter` threads options through to its inner + adapters. Failed attempts (including timeouts) retry with exponential + backoff `backoffMs * backoffMultiplier^(retry-1)` up to `maxRetries`; + an attempt exceeding `timeout` is recorded with execution status + `'timeout'`. No `options` → exactly the legacy single-attempt behavior. + - **runtime**: declarative-jobs registration in AppPlugin forwards the + authored `retryPolicy` / `timeout` to the scheduler. + + Note: JavaScript cannot forcibly cancel an in-flight handler — a timed-out + attempt is abandoned, not killed. The retry delay caps only via the + multiplier arithmetic (no maxDelay knob yet). + + Refs #3494, #1878, #1893. + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 1725857b28..8fb351b87f 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "16.1.0", + "version": "17.0.0-rc.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 bfe3557cf4..539cfca303 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-knowledge +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 26e6fdb7a8..2b938d5ed8 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "16.1.0", + "version": "17.0.0-rc.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 428b03e69f..6b83845a00 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-messaging +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 12a04261c1..2bdfd1a83f 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "16.1.0", + "version": "17.0.0-rc.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 010634da38..84726cbb8a 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,131 @@ # @objectstack/service-package +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [c073b8c] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/metadata-core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 37d7973f6e..613fd5a1fa 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "16.1.0", + "version": "17.0.0-rc.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 c81ca56d65..8afdb58acf 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,137 @@ # @objectstack/service-queue +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 965dd1799f..f57d297f5e 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "16.1.0", + "version": "17.0.0-rc.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 861a395c04..ad4fbcc984 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,137 @@ # @objectstack/service-realtime +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index c6a2eee59f..7cb7b65c0a 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "16.1.0", + "version": "17.0.0-rc.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 c591f3eca0..72878b895d 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,180 @@ # @objectstack/service-settings +## 17.0.0-rc.0 + +### Patch Changes + +- a629074: fix(auth): the second factor now obeys the operator's lockout policy instead of better-auth's defaults (#3690) + + `auth-manager.ts` constructed `twoFactor()` with a schema and nothing else, so + better-auth's built-in `accountLockout` defaults — on, 10 attempts, 15 minutes — + governed two-factor verification no matter what the admin configured. An operator + who tightened **Setup → Authentication → Account lockout threshold** to 3 got a + password stage that locked at 3 and a second factor that still locked at 10: the + stricter door was the looser one, with nothing in the UI saying so. + + `lockout_threshold` / `lockout_duration_minutes` are now projected onto + better-auth's own `accountLockout` shape (`enabled` / `maxFailedAttempts` / + `durationSeconds`, minutes converted to seconds) rather than growing a parallel + `two_factor_lockout_*` pair — one policy, one mental model, and a future upstream + field arrives as a new option instead of a conflict. The projection goes through + `applyConfigPatch`, which resets the cached better-auth instance, so a settings + change takes effect without a restart. + + Threshold `0` is deliberately **not** forwarded as `enabled: false`. It is the + password stage's "off", and a deployment may leave that stage unlocked because + rate limiting or an IdP covers it; the second factor is the last check before a + session is issued, so it keeps better-auth's default rather than being switched + off by a setting that never mentioned it. + + The threshold field is also no longer hidden behind `email_password_enabled` — + two-factor verification exists in passwordless deployments, where the setting was + previously unreachable. + + The admin **Unlock Account** action now clears both stages. It only ever reset + `sys_user`, so a user locked at the second factor had no admin escape hatch and + had to wait the duration out — survivable while that lock needed 10 failures, + routine once an operator can set the threshold to 3. The second-factor clear is + best-effort and runs after the primary write, so an account with no enrolment + still unlocks normally. + + Note the plugin caps attempts at 5 per challenge (`beginAttempt(5)`), which no + option reaches; a threshold above 5 forces a fresh challenge rather than raising + that cap. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [840ee4b] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [030125b] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/types@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 3e66ea660f..3f4f40d516 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "16.1.0", + "version": "17.0.0-rc.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 ed1e2b3cf6..1b54c2971f 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/service-sms +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 1a36658f53..e09e9ac4b2 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "16.1.0", + "version": "17.0.0-rc.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 ac182f1b13..01f371a036 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,634 @@ # @objectstack/service-storage +## 17.0.0-rc.0 + +### Minor Changes + +- 99736a0: feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3) + + A `file`/`image`/`avatar`/`video`/`audio` field that holds a `sys_file` id now + records its owner on the file: `sys_file.ref_object` / `ref_id` / `ref_field` + name the single `(object, record, field)` slot that references it, maintained on + the engine write path — claimed on insert, reconciled on update, released when + the owning record is deleted. + + **Field references are exclusive, unlike attachments.** The attachments surface + deliberately shares one file across many `sys_attachment` join rows; a field + reference is owned by at most one slot, and writing an already-owned id into a + second slot **copies the bytes into a fresh `sys_file`** rather than sharing the + row. That keeps a file's read authorisation derived from exactly one parent + record instead of the union of every referrer's — so copying a private record's + file id into a world-readable one cannot silently widen access — and it removes + reference counting from the lifecycle entirely: a file is released because its + one owner let go, never because a count came back zero. + + **Deletes nothing.** This records and releases ownership; it never tombstones, + and the `scope === 'attachments'` guardrail that keeps field-referenced files + out of the reap is untouched. Collection is a separate, gated change that must + also extend the reap guard's sweep-time re-verify in the same commit. + + Also exports `isFileIdToken` from `@objectstack/spec/data` as the single arbiter + of "is this stored string an opaque file id, or a legacy/external URL?", now + shared by the read resolver and the write claimer so the two cannot drift. + + Dormant until a field actually holds an id token: objects without file-class + fields, inline-blob values and URL-shaped values all exit before any I/O. + +- 134df4f: feat(storage): governed download for field-owned files — ADR-0104 D3 wave 2 (PR-4) + + A file owned by a record's field (`sys_file.ref_object` / `ref_id`, set by + PR-3) is now authorized on download the same way an attachment is: the caller + must be able to READ the file's parent record, or be its uploader. Previously + only `attachments`-scope files were gated and every field file kept an + anonymous capability URL. + + **Parent resolution differs by surface, and that asymmetry is the point.** An + attachment may hang off many records, so its readable-by set is the union over + its `sys_attachment` join rows. A field-owned file belongs to exactly one + record, so its readable-by set is that one record's — nothing more. Under a + shared reference model the field case would have had to union too, which is + what makes copying a file id into a more public record silently widen access. + + Denials are reported as `FILE_DOWNLOAD_DENIED` (403), distinct from the + attachments path's `ATTACHMENT_DOWNLOAD_DENIED`, since the file _belongs to_ one + record rather than being _attached to_ several. + + **`acl: 'public_read'` is the opt-out**, and now an explicit declaration rather + than the silent default every field file used to get. Genuinely public images — + anything embedded in an ``, which cannot carry a bearer token — must + declare it. + + **Dual-mode safe, gates nothing that is open today.** A pre-cutover field holds + an inline blob or an external URL, never a `sys_file` id, so no existing file + has an owner recorded and none of them start being gated. The gate engages only + for files a record's field has actually claimed, and disengages again when + ownership is released. + + *** + + Also adds `verifyFileReferences()` — the executable form of ADR-0104's R4 + acceptance gate. It compares ground truth (what records' file fields actually + hold) against recorded ownership, and classifies disagreements by whether they + could cause data loss once collection is enabled: + + - **blocking** — `unowned_reference` (a held file nothing owns), `foreign_owner` + (a record holds a file owned by another slot), `shared_reference` (one file + held by two slots, i.e. exclusivity was violated). Each would let a later reap + delete bytes a record still points at. + - **advisory** — `stale_owner` (owned but no longer held; fails toward + retention) and `unreferenced_file` (storage cost, not a correctness problem). + + The scan is read-only — it never writes, tombstones, or deletes. A ledger may + not be given authority over irreversible deletes until it has been shown to + agree with reality, so this must report zero blocking discrepancies on real + tenant data, on consecutive runs, before the gated collection change may merge. + +- fe67e34: feat(spec)!: media fields declare accept/maxSize, and the stored form is a file reference — ADR-0104 D3 wave 2 (PR-5a) + + **`accept` and `maxSize` are now declared on `FieldSchema`, and enforced on the + server.** Both were already read by the upload widgets — `field.accept`, + `field.maxSize` — while the spec did not declare them, so an author who wrote + them had the keys silently stripped at parse and the constraint simply never + existed. That is exactly the ADR-0104 failure class (a declaration accepted in + source, dropped from the contract, with no feedback). + + Now that the platform owns the file, `sys_file` carries the authoritative MIME + type and byte size, so a record write is re-checked against the declaration + where it actually binds rather than only in the browser — a client-side check is + a convenience, not a control, since any caller talking to the API directly + bypasses it. Violations raise `FileConstraintError` and fail the write. An entry + is only judged against metadata the file actually reports: a file with no + recorded MIME type cannot fail an `accept` test, and one with no recorded size + cannot fail `maxSize` — "we don't know" must not become "not permitted". + + **The stored form of a media field narrows to an opaque `sys_file` id.** + `valueSchemaFor(field, 'stored')` now yields an id for `file`/`image`/`avatar`/ + `video`/`audio`; the inline `{url, name, size, …}` blob becomes the `'expanded'` + read form, which also still admits an unresolved id (storage service absent, + file not committed) exactly as an unexpanded lookup id stays valid. + + Two legacy forms therefore stop conforming, both deliberately: + + - the **inline blob**, which is no longer stored but derived; + - an **external URL**, which was never a managed file — ADR-0104 R7 retires it + toward an explicit `url` field, and under AI authoring that is the point: it + stops "managed file" and "external link" being the same declaration. + + **Not a breaking change today.** Value-shape checking is warn-first + (ADR-0104 R1/R2): a not-yet-backfilled row still writes and the author gets a + warning naming the field. Hard rejection arrives only when a deployment opts + into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do after running the + backfill and confirming reconciliation. The `!` marks the contract change for + the v17 window, not a runtime break on upgrade. + +- 3d3fddf: feat(storage): legacy file-value backfill — ADR-0104 D3 wave 2 (PR-6) + + `backfillFileReferences()` converts the pre-reference forms a `file`/`image`/ + `avatar`/`video`/`audio` field may hold — an inline metadata blob + (`{url, name, size, …}`) or a bare URL string — into the reference form: an + opaque `sys_file` id, owned by the record's field. + + What it will and will not convert: + + - **A URL naming this platform's own resolver** (`…/storage/files/:id`) already + identifies a `sys_file`; the field is rewritten to the bare id and no bytes + move. + - **A `data:` URI** carries its bytes inline; they are uploaded, a `sys_file` is + registered, and the field is rewritten to its id. + - **An external URL** is reported, never converted. Re-hosting third-party + content is a bandwidth, licensing and privacy decision that is not a + migration's to make — ADR-0104 R7 retires these toward an explicit `url` + field, which under AI authoring is the point: it stops "managed file" and + "external link" being the same declaration. + + **Dry run by default** — nothing is written unless `apply` is set, and the + dry-run report has the same shape as the applied one so the plan can be reviewed + and diffed. **Idempotent** — a value already in reference form is recorded and + left alone, so a partially-completed run is safe to repeat. + + The backfill never writes the ownership columns itself: it rewrites the record, + and the claim hooks observe that write and record ownership. One claiming path, + so there is nothing that can disagree with itself. Run + `verifyFileReferences()` afterwards to confirm the two agree — that + reconciliation is the gate the irreversible collection change must pass. + +- fdb4f50: feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617) + + The ADR-0104 file-as-reference migration ships as a command a deployment runs + against its own database, and the deployment-level flag it records is what may + later authorise irreversible behaviour — never the platform version. + + ```bash + os migrate files-to-references # dry run: reports, writes nothing + os migrate files-to-references --apply # converts, verifies, records the flag + ``` + + The run backfills legacy file-field values (inline metadata blobs, own-resolver + URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership + ledger against what records actually hold, and — only on an `--apply` run whose + reconciliation reports **zero blocking discrepancies** — records + `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. + + **Why a flag rather than a release note.** ObjectStack is a development + platform: third-party deployments upgrade on their own schedule and their data + is not observable by anyone else, so no release-side soak can vouch for them. + The evidence has to be produced where the data is. Consequences: + + - Installing a new version never starts deleting bytes. Running the migration + and passing its self-check is the consent. + - Not run, or not passed → files are retained forever. Wasted storage, zero + data loss. + - A later failing run **clears** `verified_at`: a deployment whose data has + drifted closes its own gate. + - A dry run writes nothing at all — not the conversions, and not the flag, + even when the self-check would pass. + - External URLs stay advisory. They are not `sys_file`s, so they can never + enter collection; whether to remodel them as a `url` field is the app + author's decision (ADR-0104 R7), not a gate. + + Ships alongside: + + - `@objectstack/spec` — `DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`, + and the single `isDataMigrationFlagVerified` predicate both future consumers + (collection #3459, strict value-shape #3438) read, so the two gates cannot + disagree about the same fact. + - `@objectstack/platform-objects` — the `sys_migration` object plus + `readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`. + Reads fail toward "not verified": a gate that cannot read its evidence stays + closed. + - `@objectstack/objectql` — a read may now opt out of file-reference expansion + via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's + bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to + their expanded form before the reconciliation sees them, which reports held + references as absent — noisy `stale_owner` findings, and a missed + `unowned_reference` would have been a false pass of the collection gate. + +### Patch Changes + +- 37b1346: feat(storage): surface the sys_file id on upload-complete — ADR-0104 D3 wave 2 (PR-1) + + `POST /api/v1/storage/upload/complete` now returns the opaque `sys_file` id + (`data.fileId`), and `client.storage.upload()` surfaces it on the returned + `FileMetadata`. Previously the commit response omitted the id — the caller + could not learn which id to persist after committing an upload, so a file + field could never store a reference. + + Additive and non-breaking (new optional `fileId` on `FileMetadataSchema`; the + client falls back to the presigned id when talking to an older server). This is + the enabling foundation for file-as-reference; the storage model itself is + unchanged in this PR. + +- deb538f: fix(storage): let an object delegate file-read authorization to its service + + Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a + **legitimate approver could see a decision attachment's filename but got 403 + opening it**, found by driving app-showcase in a browser as a real non-admin + approver. + + Cause: a field-owned file's download was authorized by testing whether the + caller can READ the owning row. For an ordinary business object that is right — + row readability _is_ the access rule. For `sys_approval_action` it is the wrong + authority: the audit table is deliberately closed to ordinary approver + positions (`operation 'find' … is not permitted for positions [auditor, +everyone]`), so the test denied the very approver the attachment was filed for. + The approvals _service_ has always had the real rule, which is why the timeline + listing the attachment returned 200 while the bytes returned 403. + + An object may now name a service to answer the question instead: + + - `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes + downloads of files owned by that object's media fields. + - `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract. + - `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead` + reuses the _same_ gate `listActions` applies (visibility of the parent + request) rather than inventing a second, looser rule for the bytes. + + **Fails closed**: a declared delegate that is missing or does not implement the + method denies, rather than silently reverting to the raw read it was declared to + replace. Objects without the declaration are unchanged. + + Verified in the browser against app-showcase, both sides of the gate: the + approver now downloads the real PDF (200), and an anonymous request is still + refused (401) — the anonymous capability URL the original change closed stays + closed. A decision attachment ends up exactly as readable as the decision it + hangs off: never more, and no longer less. + +- 2c19383: fix(service-storage): stop handing out `_local/file/:key`, a URL nothing mounts (#3641) + + Three call sites built `${basePath}/_local/file/`. No registrar has ever + mounted it, so anyone who followed one got a 404. Found by the tranche-3 + storage ledger (#3636), which recorded the URL as deliberately absent and filed + this; now nothing builds it either. + + Each site is fixed according to what it could honestly do: + + - **`LocalStorageAdapter.getPresignedUpload()`** simply omits `downloadUrl` + (optional on the descriptor). It cannot construct the real capability URL — + that is keyed by `sys_file.id`, and an adapter only ever sees the storage + key. Nothing read the field anyway, which is how it survived: the + presigned-upload route builds its own `downloadUrl` + (`${basePath}/files/:fileId/url`) and ignores this one, while all three real + readers of `desc.downloadUrl` take it from `getPresignedDownload`, whose URL + _is_ mounted (`_local/raw/`). + + - **`GET /files/:fileId/url` and `GET /files/:fileId`** answer **501 + `NOT_IMPLEMENTED`** when the adapter has neither `getPresignedDownload` nor + `getSignedUrl`, instead of returning (or redirecting to) the unmounted URL. + The caller now learns the adapter is the limitation rather than chasing a + broken link. + + Behaviour change is confined to adapters implementing neither capability — + `LocalStorageAdapter` and the S3 adapter both implement `getPresignedDownload`, + so no shipped path changes. A 200/302 pointing at a 404 becomes a 501 that says + why. + + Two conformance cases added for the new branches, and mutation-checked: + restoring either dead URL fails them. + +- aff9e56: fix(i18n): translate the platform packages' declared surface, and gate all nine bundles instead of one (#3762) + + Only `platform-objects` was wired into a translation-drift check. The other + **eight** packages shipped a `scripts/i18n-extract.config.ts` that nothing ever + ran — and four of them had already drifted out of sync with the schema, exactly + the rot `pnpm check:i18n` exists to catch, one directory over. + + **Translated.** `plugin-security` (45 strings per locale), `plugin-webhooks` + (15), `plugin-audit` (8), `plugin-sharing` (7) and `service-storage` (7) are now + at **zero** untranslated declared strings in zh-CN / ja-JP / es-ES — 246 + translations. Most were newly _visible_ rather than newly missing: #3753 taught + the coverage detector to walk action `params`, `resultDialog`, `listViews` and + the rest of the declared surface, and these are what it found. + + Wording was harvested from the repo's own bundles wherever a string was already + translated somewhere (1382 unambiguous source strings), so `Created At` reads + `创建时间` here because that is what it reads everywhere else, rather than a + fresh invention. Protocol tokens are deliberately left identical across locales: + `GET` / `POST` / `PUT` / `PATCH` / `DELETE`, `ETag`, `ACL`, `URL`. + + **Gated.** `scripts/check-i18n-bundles.mjs` replaces the single-package + `pnpm check:i18n` and checks all nine. It does not restate each package's + command — it parses the one already documented in that config's own docstring + and runs it, so the documented regenerate command and the gate cannot diverge. + The coverage ratchet grows the same way, from `examples/*` to twelve configs; + eight of them sit at zero, which makes it the strict gate there. + + **Fixed a real truncation bug it exposed.** `os lint --json` on a large config + came out of a pipe cut off at exactly 65536 bytes — `console.log(big)` followed + by `process.exit(1)` tears the process down before an async pipe write drains, + while an interactive run (stdout is a TTY, written synchronously) looks perfect. + Every scripted consumer silently got invalid JSON. `emitJson` in + `packages/cli/src/utils/format.ts` waits for the write to drain and sets + `process.exitCode` instead; `lint`, `i18n check` and `i18n extract` use it. + Roughly 30 other CLI commands share the pattern and are not touched here. + + The nine documented regenerate commands also gain `--no-metadata-forms` (added + in #3768), since the Studio metadata-form baseline belongs to `platform-objects` + alone, not to a copy in every plugin. + + Not fixed here: `platform-objects`' own 77-per-locale gap is `apps.*` / + `dashboards.*` navigation and widget labels, which live outside the `objects` + subtree and cannot be scaffolded while the package extracts with + `--objects-only`. That needs an emit decision first — tracked in #3762. + +- f1a8114: fix(client,service-i18n): ledger the autonomously-mounted service routes, and repair the two i18n calls that reached nothing (#3636) + + Tranche 3 of the #3563 route audit — the last un-audited server surface. The + dispatcher ledger (#3563) and the REST ledger (#3587) each stop at their own + package boundary, and two services mount routes outside both: they reach for + the `http-server` service and register straight on `IHttpServer`, so neither + `RouteManager` nor `RestServer.getRoutes()` has ever seen them. That left the + SDK's entire storage surface, plus all of i18n, in the pre-#3563 posture: + expressed, working, guarded by nothing. + + **Ledgers + guards.** `storage-route-ledger.ts` (10 routes) and + `i18n-route-ledger.ts` (3) sit next to the registrars that mount them, each + enumerated for real — the registrar runs against a capturing mock + `IHttpServer` and its registration calls _are_ the route set, so a new route + lands with a reviewed disposition or fails CI. The client half is + `packages/client/src/service-route-ledger-coverage.test.ts`; ledgers cross the + boundary as relative source imports, never a service→client package edge. + + **Two wire-level 404s fixed.** `i18n.getTranslations` sent + `/i18n/translations?locale=xx` and `i18n.getFieldLabels` sent + `/i18n/labels/:object?locale=xx`, while every serving surface — service-i18n's + mounts, the dispatcher's HTTP mounts, and the `plugin-rest-api.zod.ts` + contract — mounts only the path form. Neither call could ever be answered. + Both had carried a green `sdk` row in the dispatcher ledger since tranche 1, + because that guard asks whether the client _method_ exists, not whether it + speaks a URL anything mounts. The client now sends the path dialect, the same + resolution #3611 gave `meta.getView`, and a new suite drives the real client + at a real router so a revert cannot pass quietly. + + **One response-shape fix.** service-i18n's success bodies omitted the + `success` flag that `ObjectStackClient.unwrapResponse` keys on, so the SDK + returned the raw `{ data: … }` wrapper against that provider while returning + the declared unwrapped shape against the dispatcher — one method, two shapes, + decided by which plugin mounted the route. Its three handlers now emit the + `{ success: true, data }` envelope the `i18n` route group declares. `data` did + not move, so direct body readers are unaffected. + + Storage audited clean: 7 routes SDK-expressed, 3 reviewed `server-only` (the + browser capability URL objectql stamps into file-field payloads, and the two + local-driver loopbacks). The chunked-upload family, flagged for triage, turned + out fully expressed. Both ledgers ratchet `gap` and `mismatch` at zero. + + Filed, not fixed: `GET {base}/_local/file/:key` is built by three call sites + and mounted by none (#3641); the cross-surface URL conformance guard that would + have caught all of the above mechanically is the capstone (#3642). + +- bd68f08: fix(service-storage,service-i18n): emit the declared error envelope, not a bare `{ error }` (#3675) + + #3636 aligned the **success** bodies of the autonomously-mounted service + routes because those were the ones breaking `ObjectStackClient.unwrapResponse`. + The error bodies were left alone and stayed a bare `{ error: '' }` — + with the code, where one existed at all, as a _sibling_ of `error` rather than + a field of it — against a contract (`BaseResponseSchema` + `ApiErrorSchema`) + that declares `{ success: false, error: { code, message } }`. + + So the same SDK method returned two different error shapes depending on which + provider mounted the route: a caller reading `body.error.message` got the real + message from the dispatcher and `undefined` from these services. All 32 sites + (27 in `storage-routes.ts`, 5 in `i18n-service-plugin.ts`) now go through a + single `sendError` helper per module — the nested-`error` shape the sibling + services already use (`settings-routes.ts`, `share-link-routes.ts`), plus the + `success` flag those two still omit and the contract requires. + + **Codes moved, and that is the breaking part.** `AUTH_REQUIRED`, + `ATTACHMENT_DOWNLOAD_DENIED` and `FILE_DOWNLOAD_DENIED` used to sit at + `body.code`; they now sit at `body.error.code`. The SDK is unaffected — it + already reads `errorBody?.code || errorBody?.error?.code`, one of the four + shapes its error path sniffs for, which is the consumer-side shim Prime + Directive #12 says to cure at the producer. The console's attachment panel + was NOT: it read the top level only, so every gated download would have + degraded from "You don't have access to download this attachment." to + "Download failed (403)". Fixed in objectui to read both dialects, since a + console build ships independently of the server it talks to. + + **Guarded both ways.** New `error-envelope.conformance.test.ts` in each + service drives every distinct error branch through the real registrar and + parses the body against the real `BaseResponseSchema` imported from + `packages/spec` — not a local restatement of it — and scans the module source + so a new route cannot quietly reintroduce the bare shape. The route ledgers + (#3563 → #3656) could never have caught this: they audit which routes exist + and whether the SDK can address them, not what comes back. + + Measured and left alone: the dispatcher does not conform either — it puts the + HTTP status in `error.code`, where the contract declares a semantic string, + and parks the real code in `details` to work around its own occupied field. + That deviation is now pinned to exactly one field by a test in + `http-dispatcher.test.ts` rather than described in prose. Also unchanged: + service-storage's success bodies are still three shapes of their own + (`{ data }`, bare `{ url }`, `{ ok, key }`, none with `success: true`) — a + non-additive change that needs its own issue, not a quiet ride along with this + one. + +- 6633337: fix(service-storage): emit the declared success envelope on all eight routes (#3689) + + #3675 moved the **error** bodies of the autonomously-mounted `/api/v1/storage/*` + routes into the declared `{ success: false, error: { code, message } }` + envelope and deliberately stopped there: unlike the errors, the success bodies + were not an additive fix. They were three shapes, none of them carrying the + `success` flag `BaseResponseSchema` declares and + `ObjectStackClient.unwrapResponse` keys on — + + | Route(s) | Was | Now | + | ---------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------- | + | the six upload routes (`/upload/presigned`, `/upload/complete`, `/upload/chunked`, `…/chunk/:i`, `…/complete`, `…/progress`) | `{ data: {…} }` | `{ success: true, data: {…} }` | + | `GET /files/:fileId/url` | `{ url }` | `{ success: true, data: { url } }` | + | `PUT /_local/raw/:token` | `{ ok: true, key }` | `{ success: true, data: { key } }` | + + — while `storage.zod.ts` declared every one of them as + `BaseResponseSchema.extend({ data })`, and `PresignedUrlResponse` and friends + are `z.infer`red from those schemas and published as the SDK's return types. + The declaration said `success: boolean`; the wire said nothing. It broke + nothing only because the storage SDK methods returned `res.json()` raw — + `any`, so TypeScript could not see the gap and nothing relied on the + declaration. That is the posture i18n was in before #3636, right up until + something did rely on it. + + **The payload moved on two routes, and that is the breaking part.** A direct + HTTP caller reading `body.url` from `GET /files/:fileId/url` must now read + `body.data.url`; one reading `body.ok`/`body.key` from the local adapter's + `PUT /_local/raw/:token` loopback must read `body.success`/`body.data.key`. + `ok` is dropped rather than kept beside `success` — it was a second, private + word for the same thing. The six upload routes are additive: callers already + destructure `.data`, and a new sibling key changes nothing. + + Every in-repo consumer was fixed first, so the two repos are not coupled by + merge order: + + - `client.storage.getDownloadUrl()` now reads through `unwrapResponse`, the + SDK's one standard envelope seam — which strips the envelope when present + and returns the body untouched when not, so a client either side of this + server change resolves the same URL. The other storage methods hand back the + whole envelope by design and were already correct. + - The console's two attachment openers (`RecordAttachmentsPanel`, + `ApprovalsInboxPage`) already read `body?.url ?? body?.data?.url`; objectui + gains tests pinning that tolerance as deliberate. + + Two schemas that were missing are now declared — `FileDownloadUrlResponse` and + `RawUploadResponse` — and `getDownloadUrl` joins `StorageApiContracts`, which + it had never been in. That absence is how its shape drifted outside the + envelope unnoticed. The two `_local/raw/:token` routes stay out of the + registry on purpose: they are the local adapter's own presign loopback, + ledgered `server-only` and addressed as an opaque signed URL rather than as an + API. + + `success-envelope.conformance.test.ts` holds the new shape in place the way + `error-envelope.conformance.test.ts` holds the error one: every route is + driven and its body parsed against the **declared schema** it answers to — not + a restatement — the retired shapes are asserted dead, and the module source is + scanned so a new route cannot bypass the `sendOk` helper. As with #3675, the + route ledgers cannot catch this class of drift: they audit which routes exist + and whether the SDK can address them, not what comes back. + +- 0bc685a: fix(storage): downloads carry the real filename + content-type, not the URL token (#3504) + + A presigned download served the bytes as `application/octet-stream` with no + `Content-Disposition`, so a browser saved the file under the opaque URL token + (e.g. `eyJrIjoiYXR0YWNo…`) instead of its real name — an approval's + `signed-contract.pdf` downloaded as a nameless blob. + + - `IStorageService.getSignedUrl` / `getPresignedDownload` take an optional + `PresignedDownloadOptions` (`filename`, `contentType`, `disposition`). + - The REST download routes (`GET /storage/files/:id/url` and `/:id`) pass the + `sys_file` record's `name` + `mime_type`. + - The local adapter carries them in the signed token; the `_local/raw` route + emits `Content-Type` + an RFC 5987 `Content-Disposition` (ASCII fallback + + `filename*=UTF-8''…` for non-ASCII names). The S3 adapter bakes the same into + the signed URL via `ResponseContentType` / `ResponseContentDisposition`. + - Default disposition is `inline`, so previewable types (PDF, images) still open + in the browser — now with the correct name when saved. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [524151c] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [4921a95] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [5487c20] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [9aa5510] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/platform-objects@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/observability@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 3e8e713ab5..d39bb1af57 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "16.1.0", + "version": "17.0.0-rc.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 3a4cc45298..d731546ea0 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,4584 @@ # @objectstack/spec +## 17.0.0-rc.0 + +### Major Changes + +- 8b9d71e: feat(client,spec)!: the SDK's `ai` namespace now expresses the AI surface that exists (#3718) + + `client.ai` and the AI service were **disjoint sets**. The namespace held three + methods — `nlq`, `suggest`, `insights` — whose URLs no repo has ever mounted + (removed in v17), while `service-ai` mounted 12 routes the SDK could not reach + at all. v17 closed the first half by deleting the dead methods. This closes the + second: the SDK now reaches every route that is meant to be tenant API surface. + + | SDK | Route | + | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | + | `ai.chat(request)` | `POST /api/v1/ai/chat` — forces `stream: false`, so the JSON mode is what you get | + | `ai.chatStream(request)` | `POST /api/v1/ai/chat` — `AsyncIterable` of UI Message Stream frames | + | `ai.complete(request)` | `POST /api/v1/ai/complete` | + | `ai.models()` | `GET /api/v1/ai/models` — the ADR-0028 plan-filtered picker list | + | `ai.conversations.create/list/get/update/delete/addMessage` | the six `/api/v1/ai/conversations` routes | + + `ai.chatStream` returns a promise for an async iterable rather than being an + async generator, so the request is issued — and an HTTP error thrown — when you + call it, not when you first iterate. + + **Where the server is.** `service-ai` is a Cloud/EE package in the `cloud` + repo; this repo only proxies `/api/v1/ai/**` and 404s `AI service is not +configured` without it. Check `discovery.services` before calling, exactly as + for any other plugin-provided namespace. For a React chat UI, `useChat()` + (`@ai-sdk/react`) is still the better client — it speaks the same protocol + `ai.chatStream` parses and owns message state; these methods are for callers + that are not components. + + **Breaking — the spec's dead AI declarations are retired.** All three had no + implementation anywhere and no runtime consumer: + + - `Ai{Nlq,Suggest,Insights}{Request,Response}[Schema]` → replaced by the wire + shapes of the real routes: `AiChat{Request,Response}`, `AiStreamChunk`, + `AiCompleteRequest`, `AiModelsResponse`, `AiConversation`, `AiMessage`, + `{Create,List,Update}AiConversation*`. The six retired JSON Schemas are + dropped from `json-schema.manifest.json` (deliberate retirement, #2978). + - `DEFAULT_AI_ROUTES` → deleted, and `getDefaultRouteRegistrations()` returns 8 + groups instead of 9. It declared the three phantom endpoints and had no + runtime consumer; re-declaring the real ones here would recreate the same + illusion, since they are mounted from another repo. + - `AiProtocol` (`aiNlq?` / `aiSuggest?` / `aiInsights?`) → deleted. Nothing + implemented it and nothing dispatched through it. The real server contract is + `IAIService` + `IAIConversationService` in `@objectstack/spec/contracts`. + + **The guard.** `/api/v1/ai/` becomes a bounded prefix exemption in the capstone + (#3642) alongside the control plane — bounded from both ends: only `ai.*` may + use it, and the namespace must still be reaching it. That is not a + wave-through. The reachability check lives where the routes are: + `cloud`'s `packages/service-ai/src/ai-route-ledger.conformance.test.ts` reads + the table `buildAIRoutes()` returns and drives this SDK against it, so an + `ai.*` URL that stops resolving fails a test in the repo that mounts it. The + wildcard-only bound stays **0** — these URLs never touch the `* /ai/**` row, + which is what certified three dead methods for years. + + The four replaced client tests are worth naming: they mocked `fetch` and + asserted the URL the client _built_, never that anything answered it, and + passed for years against endpoints that did not exist. The new ones assert only + what this repo can honestly know — verb, path, and the body decisions the SDK + makes for you (`stream: false` on `chat`, the 204 on `delete`, SSE frame + parsing) — and leave "does it resolve" to the ledger next to the routes. + +- e47b342: feat!: require Node.js 22 — promise the runtime we actually test (#3825) + + Every published package declared `engines.node: ">=18.0.0"`. **Node 18 reached + end-of-life on 2025-04-30 and Node 20 on 2026-04-30**, so the compatibility + promise covered two runtimes nobody patches — and, after #3830 moved CI to Node + 22, two runtimes nothing in this repo verifies. + + That left the promise and the evidence with **no overlap at all**: + + | | Node version | + | ----------------------------------------------------------------------------------------------- | ------------ | + | What CI validates every PR on | **22** | + | What `release.yml` publishes from | **22** | + | What every shipped Docker image runs (`docker/Dockerfile`, `blank` template, self-hosting docs) | **22** | + | What `engines.node` promised users | **>=18** | + + `engines.node` is now `>=22.0.0` across all 50 manifests. This is the honest + floor: it is the only runtime the packages are built, tested and shipped on. + + ## Migration + + **If you are on Node 22 or newer, nothing changes.** Node 24 (Active LTS since + 2025-10-28) and Node 26 both satisfy the new range. + + If you are on Node 18 or 20, upgrade to Node 22+. Both are past end-of-life and + receive no security patches: + + ```bash + nvm install 22 && nvm use 22 + ``` + + npm and pnpm surface an unsatisfied `engines` as an **`EBADENGINE` warning**, not + a hard failure, so an existing install will not break the moment you upgrade — + but the package is no longer tested on that runtime, and the failures are the + kind that do not announce themselves. #3812 is the worked example: a native + dependency whose `engines` required a newer Node loaded anyway on the older one + and then killed the test worker at the process level, with no JS error and a + summary that still said "passed". + + If your CI pins Node, pin it to 22 as well — running your gates on a runtime + your dependencies no longer support is exactly the split this change closes. + + ## Also updated + + The "Node 18+" prerequisite was restated in ten user-facing places + (`README.md`, `CONTRIBUTING.md`, the getting-started and deployment docs, the + todo example, and the `objectstack-platform` skill's `compatibility` field). + All now say 22. Changelogs and ADRs are historical records and were left alone. + +- 4ed7ed4: feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710) + + **BREAKING — `allowExport` unset no longer means "inherit read".** Reading a + record and taking a bulk machine-readable copy of the whole table are different + privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite + "Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so. + + ### Migration — FROM → TO + + | | before | after | + | -------------------- | ----------------------------------- | -------------------------- | + | `allowExport` unset | export **allowed** (inherited read) | export **denied** | + | `allowExport: false` | export denied | export denied (unchanged) | + | `allowExport: true` | export allowed | export allowed (unchanged) | + + **The one-line fix:** add `allowExport: true` to the object entry (or the `'*'` + wildcard) of every permission set whose holders should keep exporting. + + ```ts + objects: { + deal: { allowRead: true, allowExport: true }, // ← add the grant + } + ``` + + Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set + that never exported is unaffected. + + **Who is affected.** Package-shipped sets are re-seeded on upgrade, so the + built-ins are handled for you — `admin_full_access` and `organization_admin` now + carry `allowExport: true` explicitly. **Environment-authored sets are not**: any + custom set whose users export must be edited. `member_default` deliberately does + NOT carry the grant, so ordinary authenticated users lose export until an admin + grants it — that is the point of the flip, not an oversight. + + **Merge semantics.** Most-permissive, exactly like the CRUD bits: any set + granting `true` grants export. `false` and unset are the same outcome; `false` + is authoring intent, not a veto, because permission sets are additive capability + containers (ADR-0090). + + **Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no + longer confer export. Separating "may see all data" from "may take a bulk copy" + is the segregation-of-duties case the axis exists for. + + ### Also in this change + + - **spec** — a set carrying `allowExport` is now **high-privilege** + (`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` / + `guest` audience anchors. Without this the opt-in was defeatable by binding an + export-granting set to `everyone`. One predicate, so the runtime anchor gate, + the `@objectstack/lint` security-posture rule and the install-time suggestion + surface all pick it up together. + - **spec / plugin-security** — `ExplainOperationSchema` gains `export`, so + `explain` can answer _why_ a caller got `403 EXPORT_NOT_PERMITTED`. It + explains as `read ∧ the export grant`: `object_crud` reports the conjunction + and attributes the granting set, while every data-shaped layer + (requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed + as the `find` the export actually performs — asking the RLS compiler about an + `export` operation would match no policy and wrongly report "no RLS applies". + `readFilter` is surfaced for `export` as it is for `read`. + - **plugin-reports** — closes the reports side door (#3710). A report rendered + as `csv`/`json` is the same bulk copy of the same object, so it is gated by + the same `ISecurityService.canExport`. Enforced in `executeReport`, which the + interactive run, the ad-hoc run and the scheduled dispatch all funnel through; + `scheduleReport` additionally refuses at create time so an author is not told + at 3am. A schedule created while granted stops delivering once the grant is + revoked. `html_table` stays a read — it is a rendered view, not a bulk copy. + Deployments without `plugin-security` are unaffected (no permission sets + exist, so the axis does not apply). + +- acbf364: feat(spec)!: retire the last three deprecated authorable aliases (#3855) + + Protocol 17 removes the three keys that a schema transform used to fold into a + canonical slot and drop from the parsed output. Every slot now has exactly one + spelling. + + ## Migration + + | Removed | Use instead | Value shape | + | --------------------------- | ------------------------- | -------------------------------------- | + | `action.execute` | `action.target` | unchanged — a handler / flow / URL ref | + | `field.conditionalRequired` | `field.requiredWhen` | unchanged — a CEL predicate | + | `agent.knowledge.topics` | `agent.knowledge.sources` | unchanged — a list of source tags | + + All three are **pure key renames**. Nothing about the value changes, and no + runtime behaviour changes: each alias was already lowered into its canonical key + at parse time and erased before any consumer saw it, so what shrinks is the + authorable surface, not the semantics. + + **Run `os migrate meta --from `.** It rewrites your source + mechanically — these renames are registered as protocol-17 chain steps, so the + tool applies all three (and every earlier step you skipped) in one pass. Manual + alternative: rename the key. That is the entire fix. + + ```diff + - actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }] + + actions: [{ name: 'convert', type: 'script', target: 'convertHandler' }] + + - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } } + + fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } } + + - knowledge: { topics: ['faq', 'policies'], indexes: ['docs'] } + + knowledge: { sources: ['faq', 'policies'], indexes: ['docs'] } + ``` + + ## Why these reject instead of being ignored + + None of the three schemas is `.strict()`, so deleting a key outright makes Zod + **silently strip** it: the metadata would parse clean and the setting would + simply never take effect — a script action bound to nothing, a field that is + never required, an agent recruiting no RAG context. `FieldSchema` already + carries a comment about the last time that happened (`dataQuality` / `cached`, + #3726 / #3733). + + So each removed key is **tombstoned**: it stays declared as `never`, which makes + writing it a `tsc` error at the authoring site _and_ a parse error carrying the + rename. You cannot lose the setting quietly. + + ## Where to find this if you missed it + + The removal is in the machine-readable change manifest (`spec-changes.json`, + ADR-0087 D4) as three protocol-17 conversions. Per-major manifests **compose**, + so jumping several majors at once still yields a single answer rather than N + changelogs to reconcile — the generated upgrade guide and the `spec_changes` MCP + tool are both projections of that record. + + ## Also removed + + `lintDeprecatedAliases` and its rule-id exports (`ACTION_TARGET_EXECUTE_CONFLICT`, + `FIELD_REQUIREDWHEN_CONDITIONALREQUIRED_CONFLICT`, + `AGENT_KNOWLEDGE_SOURCES_TOPICS_CONFLICT`, `DeprecatedAliasFinding`, + `formatDeprecatedAliasFinding`). That pass existed to warn when an author + declared both an alias and its canonical key, because the parse resolved the + conflict silently. With the aliases gone the parse **rejects** instead, which is + strictly louder — the rule has no subject left. If you imported any of these, + delete the import; there is no replacement because the condition it reported can + no longer occur. + + The CLI's inline-handler lowering also stops binding a function on `execute`. It + runs before the parse, so binding it there would have kept the removed alias + quietly working for one authoring style while every other style rejected it. + +- f24cb83: feat(spec)!: dissolve the ObjectStackProtocol composition alias — ADR-0076 D9 end-state (v17, #3606) + + The transitional union of the twelve per-domain contracts (and its parallel + `ObjectStackProtocolSchema` Zod object + `ObjectStackProtocolZod` inferred + type, 171 schema lines) is removed. Capability availability comes from the + runtime discovery `services` registry — a static union was its degraded + snapshot (ADR-0076 rev.7 verdict). Depend on the narrowest per-domain slice + (`DataProtocol`, `MetadataProtocol`, …; composition precedent: REST's + `DataProtocol & MetadataProtocol`, A1.5/#3028). + `ObjectStackProtocolImplementation` now declares exactly the four domains it + actually provides (Data/Metadata/Analytics/Package) — the D10 "facade never + implemented the other domains" reality, now enforced by the type system. + BREAKING for anything importing the alias or the Zod schema; no runtime + behavior change. + +- 5dbbb92: release!: promote the accumulated launch-window train to v17.0.0 (RC cycle) + + Anchor changeset for the v17 major. The lockstep group applies the highest + bump across all pending changesets to every package, so this single `major` + promotes the whole train — every other pending changeset keeps its own + `minor`/`patch` declaration and its own narrative. + + **Why a major, when the launch-window policy ships breaking changes as + `minor`:** this train's breaking density is the highest since the policy was + adopted — the `ApiMethod` enum shrink (#3543, compile-time breaking for TS + authors), the GraphQL surface removal, the ADR-0104 field value-shape write + cutover, and the retirement of several dead spec clusters all ride together. + Publishing that set as a bare minor would auto-upgrade every `^16.x` consumer + into it on their next install. A major puts the version-number signal back: + caret ranges hold at 16.x until a consumer opts in. + + **RC cycle:** this lands inside Changesets pre-mode (`rc` tag), so the train + publishes as `17.0.0-rc.N` — nothing reaches `latest` until `changeset pre +exit`. Downstream validation during the RC window: cloud / objectui / + examples upgrade against the RC, the dogfood gate and the third-party + consumer gate (#2035) run against it, and legacy `apiMethods` strip warnings + are watched for the deny-all cliff. + + Migration: each breaking change's own changeset carries its FROM → TO guide + (grep the CHANGELOG for `!:` entries); the ApiMethod shrink additionally + ships a reporter codemod (`scripts/codemod/apimethods-legacy-to-primitives.mjs`). + +### Minor Changes + +- 50616d9: feat(spec,cli): warn the author when a deprecated action alias is discarded (#3743) + + #3742 made `target` beat the deprecated `execute` alias everywhere and had the + `ActionSchema` transform **drop** the alias from its output, so "two different + scripts for one button" became unrepresentable. What it left behind: an author + who declares both slots with different values still loses one of the two + handlers they wrote, **silently**. Per Prime Directive #12 that belongs at + authoring time, so it is now reported there. + + **New rule — `action-target-execute-conflict` (advisory).** An action declaring + both `target` and `execute` with different values gets a warning naming both + handlers, stating that `target` wins, and giving the one-line fix (delete + `execute`). Identical values in both slots are harmless duplication and stay + quiet. It never fails the build: the resulting stack is well-defined — the cost + is a handler that never runs, not a broken artifact. + + The rule must run **pre-parse**, because the parse is what consumes the alias: + once `ObjectStackDefinitionSchema` has run there is no `execute` key left to + report. It therefore lives in `@objectstack/spec` + (`lintDeprecatedAliases`, exported from the package root) and is wired into + both layers that perform the discard: + + - **`defineStack`** — the dominant authoring path, and the one that consumes the + alias earliest: it parses inside your own config module, so by the time + `os build` loads that module the alias is already gone. It now warns on the + console before parsing (once per distinct conflict per process). + - **`os build` / `os validate`** — a new pre-parse pass covering stacks that + skip strict `defineStack`: a plain object default-export, + `defineStack(…, { strict: false })`, and inline function handlers (`target` is + `z.string()`, so those cannot pass strict `defineStack` and are lowered by the + CLI instead). Both commands lint the same input, so they agree by construction + (#3782). + + Each layer reports only its own discards, so one authored conflict produces + exactly one warning however the stack is compiled. + + **Behaviour fix in the same contract.** #3742 fixed compile-time precedence by + probing for a _callable_ `target` first, which left one combination still + resolving the alias's way: a **string** `target` beside a **function** `execute` + bound the alias and then overwrote the canonical ref the author wrote. `target` + now wins in every combination of string/function across the two slots, matching + the `ActionSchema` transform — so the new warning states one precedence rule + that is true everywhere. If you relied on an inline `execute` function winning + over a string `target`, move it into `target`; the warning names the action. + + Authoring is otherwise unchanged: `execute` alone is still accepted, still + lowered into `target`, and still documented. + +- 08b5a3d: fix(action): one precedence for `target` vs the deprecated `execute` — lower the alias, then drop it (#3713) + + `execute` is the deprecated alias of `target`, and three readers resolved "the + author declared both" in **two opposite directions**: + + | Reader | Preferred | + | ------------------------------------- | --------- | + | `ActionSchema` transform (spec) | `target` | + | objectui `ActionRunner.executeScript` | `execute` | + | CLI compile step (`lowerCallables`) | `execute` | + + So `defineAction({ type: 'script', target: 'preferredHandler', execute: 'legacyHandler' })` + ran `preferredHandler` server-side and `legacyHandler` client-side — two + different scripts for one button, silently, with no error anywhere. Low + frequency (it needs an author to set both, which happens mid-migration or by + copy-paste), but the failure mode is "the wrong code ran". + + **`target` now wins everywhere, and the alias is removed from the parsed + output** — the same "canonical wins, alias disappears" shape as + `agent.knowledge.topics` → `sources`. The conflict is now _unrepresentable_ + rather than merely agreed-upon: no renderer can see a second slot to disagree + about. Worth noting the server runtime never read `execute` at all + (`isHeadlessInvokableAction` gates on `target || body`; dispatch probes + `target`/`name`), so authoring `execute` worked _solely_ because it was lowered + at parse time — dropping it costs the server nothing. + + The CLI's inline-handler lowering had the same bug in compile-time form: with a + function in both slots it bundled the `execute` one and then overwrote + `action.target` with that ref, silently discarding the function the author + declared on `target`. It now probes `target` first and drops the alias. + + **Authoring is unchanged** — `execute` is still accepted on input (`ActionInput`), + still lowered to `target`, and still listed in the reference docs. Nothing to + migrate in your app metadata. + + **Consumers of the parsed metadata**, however, must read the canonical slot: + + - FROM: `parsedAction.execute` → TO: `parsedAction.target` + - One-line fix: delete the alias fallback, e.g. `action.execute || action.target` + becomes `action.target`. + + `z.infer` no longer carries `execute`, so any such reader + fails to compile rather than silently reading `undefined`. The objectui + `ActionRunner` counterpart ships separately. + +- 4727eb8: feat(spec): reject unknown keys on an action param instead of stripping them (#3405) + + `ActionParamSchema` was zod-default `.strip`: any key it does not declare was + **discarded silently** and the param went on parsing. That is the mechanism + behind the `reference` bug — an author wrote a correct, clearly intended + `reference: 'sys_user'`, the key was eaten, and the param dialog rendered a text + box asking a human to paste a UUID. Adding `reference` fixed that one key; the + mechanism that swallowed it stayed, so the next mis-spelled key would fail the + same way, with the same zero feedback (ADR-0078 no-silently-inert-metadata, + ADR-0049 enforce-or-remove). + + An action param is now `.strict()`. An undeclared key is a parse error naming the + offending key, and — when the key is a recognisable spelling of a declared one — + the canonical key to use instead: + + ``` + Unrecognized key(s) on this action param: `reference_to`. Until #3405 these were + dropped silently — the param still parsed, so a mis-spelled config shipped as a + control that quietly ignored it. Did you mean `reference_to` → `reference`? + ``` + + **Migration.** A param that previously carried an extra key now fails to parse. + The fix is to correct or remove that key; the error names it. Common mappings — + case/underscore slips are matched automatically, these are the ones that need a + different word: + + | Wrote | Use | + | ----------------------------------------------- | -------------- | + | `reference_to` / `referenceTo` / `targetObject` | `reference` | + | `visibleWhen` / `visibleOn` / `visibility` | `visible` | + | `description` / `help` | `helpText` | + | `default` | `defaultValue` | + + Declared keys are unchanged: `name`, `field`, `objectOverride`, `label`, `type`, + `required`, `options`, `placeholder`, `helpText`, `defaultValue`, `multiple`, + `accept`, `maxSize`, `reference`, `defaultFromRow`, `visible`, `requiresFeature`. + +- fa3d0cf: feat(spec): field runtime value-shape contract — ADR-0104 phase 1 (D1) + + `@objectstack/spec/data` now owns the runtime VALUE shape of every field type + (`field-value.zod.ts`): semantic type classes (`STRING_VALUE_TYPES`, + `NUMERIC_VALUE_TYPES`, `REFERENCE_VALUE_TYPES`, `FILE_REFERENCE_TYPES`, + `STRUCTURED_JSON_TYPES`, `MULTI_CAPABLE_TYPES`, …), the shared + `isMultiValueField`, and `valueSchemaFor(field, 'stored' | 'expanded')`. The + four consumers that each hand-copied this knowledge (objectql record-validator, + rest import-coerce, driver-sql column classification, qa conformance) now + derive from the spec, and the field-zoo round-trip MATRIX is asserted against + the contract so the two cannot drift. + + **Write-path change (objectql, warn-first):** previously-unvalidated types — + single `lookup`/`master_detail`/`user`/`tree`, `file`/`image`/`avatar`/ + `video`/`audio`, `location`, `address`, `composite`, `repeater`, `record`, + `vector` — are now checked against the contract. A violation **logs a warning + and passes** in this release (legacy rows must not strand their records); + set `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1` to enforce as a + `400 VALIDATION_FAILED`. The flip to strict-by-default rides a later minor + (ADR-0104 R1/R2). + + **Deprecations (removal rides the next spec major), FROM → TO:** + + - `CurrencyValueSchema` (`{value, currency}`) → none. A `currency` field's + value is a **bare number** everywhere in the runtime (validator, SQL `float` + column, import coercion, field-zoo oracle); the currency code lives in field + config. Use `valueSchemaFor({type: 'currency'})`. + - `LocationCoordinatesSchema` (`{latitude, longitude}`) → `LocationValueSchema` + (`{lat, lng}`) — the shape the platform actually stores. + - `AddressSchema` is **adopted** (unchanged) as the enforced `address` value + contract via `AddressValueSchema`. + + No stored data changes shape; the contract codifies deployed reality + ("reality wins", ADR-0104 D1). + +- af5a224: feat: enforce declared action-param contract at dispatch — ADR-0104 phase 2 (D2) + + An action's declared `params[]` (`type` / `required` / `multiple` / `options` / + `reference`) was a complete value contract that only ever informed the client + dialog — the server passed `reqBody.params` straight to the handler unvalidated + (REST `handleActions` and the MCP `invokeBusinessAction` path), and handlers + read an untyped bag. D2 makes the declaration enforced and typed. + + - **`@objectstack/spec/ui`** now exports `validateActionParams` (+ + `ResolvedActionParam`, `ActionParamIssue`, `ACTION_PARAM_BUILTIN_KEYS`): a + pure check that validates a params bag against resolved param declarations, + reusing the D1 `valueSchemaFor` so option membership, `multiple` arrays and + reference-id shape all ride the one value contract. Also exports the typed + authoring surface `ActionHandler` / `ActionHandlerContext` / + `ActionEngineFacade` — annotate a handler with `ActionHandler` instead of + `(ctx: any)`. + - **Dispatch (runtime)**: both the REST and MCP action paths resolve the + action's declared params (field-backed params resolved through the referenced + object field) and validate the request bag **before the handler runs** — + required presence, per-type value shape, and unknown keys (the dispatcher's + own `recordId` / `objectName` are allowlisted). + + **Warn-first rollout (ADR-0104 R3).** A violation is **logged and passes** by + default — params that were silently wrong before keep working while the drift + becomes visible. Set `OS_ACTION_PARAMS_STRICT_ENABLED=1` to reject with a + `400 VALIDATION` (REST) / an error (MCP). Actions that declare no `params` are + untouched (nothing to validate against). The flip to strict-by-default rides a + later minor once telemetry is quiet. + + Not included: file/image params becoming `sys_file` references — that depends + on file-as-reference (ADR-0104 D3). Per-name static typing of `ctx.params` from + the literal `params` array is a deferred DX nicety; the runtime guarantee holds + regardless. + +- 71f76e1: feat(spec): declared media value shape — ADR-0104 D3 wave 1 (file/image/avatar/video/audio) + + `@objectstack/spec/data` now exports `FileValueSchema` — the declared inline + form the platform stores today for the whole `FILE_REFERENCE_TYPES` class + (`file` / `image` / `avatar` / `video` / `audio`): `{ url, name?, size?, +mimeType?, alt?, duration? }` with `url` required. It replaces D1's loose + transitional union, so `valueSchemaFor(fileField, 'stored')` now catches a + malformed media value (a number, an empty object, a url-less `{ name }` + fragment) that was previously waved through as an opaque payload — while still + admitting the opaque id/url string form for import compatibility. + + This is **wave 1** of ADR-0104 D3 (see the 2026-07-24 addendum): the value-shape + contract only. It is single-repo, additive, and carries no migration — the + enforcement rides D1's existing warn-first write-path posture, so deployed + records with a legacy media value are not stranded. `accept` / `maxSize` field + config, the `sys_file` reference storage model, GC, and governed download are + **wave 2** (a protocol-major migration), deliberately not in this change. + +- 99736a0: feat(storage): exclusive field-reference file ownership — ADR-0104 D3 wave 2 (PR-3) + + A `file`/`image`/`avatar`/`video`/`audio` field that holds a `sys_file` id now + records its owner on the file: `sys_file.ref_object` / `ref_id` / `ref_field` + name the single `(object, record, field)` slot that references it, maintained on + the engine write path — claimed on insert, reconciled on update, released when + the owning record is deleted. + + **Field references are exclusive, unlike attachments.** The attachments surface + deliberately shares one file across many `sys_attachment` join rows; a field + reference is owned by at most one slot, and writing an already-owned id into a + second slot **copies the bytes into a fresh `sys_file`** rather than sharing the + row. That keeps a file's read authorisation derived from exactly one parent + record instead of the union of every referrer's — so copying a private record's + file id into a world-readable one cannot silently widen access — and it removes + reference counting from the lifecycle entirely: a file is released because its + one owner let go, never because a count came back zero. + + **Deletes nothing.** This records and releases ownership; it never tombstones, + and the `scope === 'attachments'` guardrail that keeps field-referenced files + out of the reap is untouched. Collection is a separate, gated change that must + also extend the reap guard's sweep-time re-verify in the same commit. + + Also exports `isFileIdToken` from `@objectstack/spec/data` as the single arbiter + of "is this stored string an opaque file id, or a legacy/external URL?", now + shared by the read resolver and the write claimer so the two cannot drift. + + Dormant until a field actually holds an id token: objects without file-class + fields, inline-blob values and URL-shaped values all exit before any I/O. + +- fe67e34: feat(spec)!: media fields declare accept/maxSize, and the stored form is a file reference — ADR-0104 D3 wave 2 (PR-5a) + + **`accept` and `maxSize` are now declared on `FieldSchema`, and enforced on the + server.** Both were already read by the upload widgets — `field.accept`, + `field.maxSize` — while the spec did not declare them, so an author who wrote + them had the keys silently stripped at parse and the constraint simply never + existed. That is exactly the ADR-0104 failure class (a declaration accepted in + source, dropped from the contract, with no feedback). + + Now that the platform owns the file, `sys_file` carries the authoritative MIME + type and byte size, so a record write is re-checked against the declaration + where it actually binds rather than only in the browser — a client-side check is + a convenience, not a control, since any caller talking to the API directly + bypasses it. Violations raise `FileConstraintError` and fail the write. An entry + is only judged against metadata the file actually reports: a file with no + recorded MIME type cannot fail an `accept` test, and one with no recorded size + cannot fail `maxSize` — "we don't know" must not become "not permitted". + + **The stored form of a media field narrows to an opaque `sys_file` id.** + `valueSchemaFor(field, 'stored')` now yields an id for `file`/`image`/`avatar`/ + `video`/`audio`; the inline `{url, name, size, …}` blob becomes the `'expanded'` + read form, which also still admits an unresolved id (storage service absent, + file not committed) exactly as an unexpanded lookup id stays valid. + + Two legacy forms therefore stop conforming, both deliberately: + + - the **inline blob**, which is no longer stored but derived; + - an **external URL**, which was never a managed file — ADR-0104 R7 retires it + toward an explicit `url` field, and under AI authoring that is the point: it + stops "managed file" and "external link" being the same declaration. + + **Not a breaking change today.** Value-shape checking is warn-first + (ADR-0104 R1/R2): a not-yet-backfilled row still writes and the author gets a + warning naming the field. Hard rejection arrives only when a deployment opts + into `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` — which it should do after running the + backfill and confirming reconciliation. The `!` marks the contract change for + the v17 window, not a runtime break on upgrade. + +- fdb4f50: feat(migrate): `os migrate files-to-references` — a data migration with a self-check, gated per deployment (#3617) + + The ADR-0104 file-as-reference migration ships as a command a deployment runs + against its own database, and the deployment-level flag it records is what may + later authorise irreversible behaviour — never the platform version. + + ```bash + os migrate files-to-references # dry run: reports, writes nothing + os migrate files-to-references --apply # converts, verifies, records the flag + ``` + + The run backfills legacy file-field values (inline metadata blobs, own-resolver + URLs, `data:` URIs) into owned `sys_file` references, reconciles the ownership + ledger against what records actually hold, and — only on an `--apply` run whose + reconciliation reports **zero blocking discrepancies** — records + `sys_migration { id: 'adr-0104-file-references', verified_at, blocking: 0 }`. + + **Why a flag rather than a release note.** ObjectStack is a development + platform: third-party deployments upgrade on their own schedule and their data + is not observable by anyone else, so no release-side soak can vouch for them. + The evidence has to be produced where the data is. Consequences: + + - Installing a new version never starts deleting bytes. Running the migration + and passing its self-check is the consent. + - Not run, or not passed → files are retained forever. Wasted storage, zero + data loss. + - A later failing run **clears** `verified_at`: a deployment whose data has + drifted closes its own gate. + - A dry run writes nothing at all — not the conversions, and not the flag, + even when the self-check would pass. + - External URLs stay advisory. They are not `sys_file`s, so they can never + enter collection; whether to remodel them as a `url` field is the app + author's decision (ADR-0104 R7), not a gate. + + Ships alongside: + + - `@objectstack/spec` — `DataMigrationFlagSchema`, `FILE_REFERENCES_MIGRATION_ID`, + and the single `isDataMigrationFlagVerified` predicate both future consumers + (collection #3459, strict value-shape #3438) read, so the two gates cannot + disagree about the same fact. + - `@objectstack/platform-objects` — the `sys_migration` object plus + `readDataMigrationFlag` / `isDataMigrationVerified` / `recordDataMigrationRun`. + Reads fail toward "not verified": a gate that cannot read its evidence stays + closed. + - `@objectstack/objectql` — a read may now opt out of file-reference expansion + via the spec's `RAW_FILE_VALUES_CONTEXT_KEY`, and the storage service's + bookkeeping/scan reads do. Without it the read resolver rewrites stored ids to + their expanded form before the reconciliation sees them, which reports held + references as absent — noisy `stale_owner` findings, and a missed + `unowned_reference` would have been a false pass of the collection gate. + +- 1bd5652: feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — the + `delegated_admin` org role, capped so it cannot mint authority (#3697) + + D8 authorizes invitation _placement_ against the issuer's `adminScope` + (ADR-0090 D12), so a delegated plant admin may invite only into their own + subtree. That gate is implemented, unit-proven and reachable — but no principal + could reach it in a state where it did anything: + + - better-auth grants `invitation: ["create"]` to `owner` and `admin` only + (`memberAc` holds `invitation: []`, which every other registered role + inherits); + - under a wall-enforcing posture, owners and admins are auto-elevated to + `organization_admin` (`auto-org-admin-grant.ts`), which carries the wildcard + `modifyAllRecords` that makes `isTenantAdmin()` true — and the gate + short-circuits on tenant admins. + + The two sets were disjoint. Issuance placement was bounded by the Layer 0 org + wall (real, and correct) but never by `adminScope`, so D8's motivating story — + "a plant admin invites into their own subtree without a platform admin + finishing the job" — could not happen. + + **Two pieces, and they only ship together.** + + **1. The role.** `delegated_admin` is now registered with the organization + plugin as `memberAc.statements` plus `invitation: ["create"]` — the one + membership grade that may reach `/organization/invite-member` without being an + org admin. Deliberately _not_ `invitation: ["cancel"]`: better-auth's cancel + route checks the permission with no inviterId attribution, so it would mean + "cancel anyone's pending invitation in the org". + + The role carries no ObjectStack authority by construction — `mapMembershipRole` + passes it through as a position name, and with no `sys_position_permission_set` + binding that name resolves to nothing. Role = _can reach the endpoint_; + `adminScope` = _what the endpoint permits_. + + `sys_member.role` and `sys_invitation.role` each gain `delegated_admin` as a + fourth option. Those selects are **enforced on write** — better-auth's own + invitation and membership inserts are validated like any other row — so + registering the role with the org plugin without listing it in both would have + produced a role nobody could hold and nobody could hand out + (`ValidationError: role must be one of: owner, admin, member`). That is exactly + how the end-to-end regression caught it, twice; neither unit test could. The + three non-English translation bundles carry the English label for the new option + until localized. + + **2. The role cap**, in the framework's own `beforeCreateInvitation` hook, + beside the D8 placement gate. Registering the role alone would have been a + four-step privilege escalation: better-auth's only role-level cap on _what role + you may invite someone as_ is its `creatorRole` check (default `owner`), which + blocks inviting an **owner** but not an **admin** — and an accepted `admin` + membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. A + subtree-scoped delegate could have manufactured a tenant admin, with every + existing defense off the path (`sys_member` is not a `GOVERNED_OBJECT`, and the + acceptance-time membership write runs under better-auth's context, not the + issuer's). + + The cap refuses an invitation whose role outranks the issuer's own, and + restricts a below-admin issuer to plain `member` — not merely "not admin/owner", + because an app-registered role projects into `current_user.positions` and may be + bound to permission sets, making it a capability channel too. A delegate's + channel for capability is the invitation's _placement_ intent, which the D12 + gate allowlists position-by-position. The cap applies to every invitation, + placement-carrying or not (the escalation is independent of placement), and + fails closed: an issuer role that cannot be resolved confers nothing above a + plain member. + + **What changes for deployments.** One new class of principal exists: members + holding the `delegated_admin` org role, who can invite into the org — as + `member` only, into the subtree their `adminScope` allows. It is opt-in twice + over (someone must set the membership role _and_ grant an adminScope set), so a + default deployment changes not at all. Org owners and admins are unaffected. + + Also exported: `MEMBERSHIP_ROLE_DELEGATED_ADMIN` from `@objectstack/spec`, so + console and control-plane surfaces name the role from one place. + +- 14252d3: feat(approvals): cross-organization approver targeting — a plant document can + require a group-side sign-off (ADR-0105 D9) + + One organization id used to decide three different things at once in + `openNodeRequest`: where the request row lives, where its inbox index rows + live, and **where its approvers are looked up**. The first two are the + request's own organization by definition. The third is not — a group CFO holds + her `cfo` position in the GROUP organization while the purchase order she signs + off lives in the PLANT organization. `expandPositionUsers('cfo', )` + matched nobody, the slot fell back to the dead `position:cfo` literal, and a + group escalation could not be expressed at all. + + An approver may now declare which organization's directory resolves it: + + ```yaml + approvers: + - { type: position, value: plant_manager, group: plant } + - { type: position, value: cfo, organization: $root, group: finance } + behavior: per_group + ``` + + - **`$root` / `$parent`** walk D6's `parent_organization_id` tree, so the two + common intents need **no deployment knowledge** — flow metadata is portable + across environments while organization ids are minted per deployment. A slug + covers what the symbols cannot, notably a **sibling** organization (a + shared-services centre approving payables for every plant). + - Declared **per approver**, so one node can require a plant manager and a + group CFO in parallel. A node-level form cannot express that without + splitting into serial nodes, which changes the semantics. + - **Bounded, not free:** the target must share a `parent_organization_id` root + with the request's organization. The rule reads only the organization tree — + never the submitter — so one flow routes identically for everyone. + + Everything else fails loudly rather than quietly: + + - a non-`group` posture **refuses** the declaration (a `group` → `isolated` + migration must not silently reroute approvals); + - an approver type with no org-scoped directory (`user` / `field` / `manager` / + `team`) refuses it too, and a new `approval-approver-cross-org-unsupported` + lint catches that at author time; + - a targeted approver holding no membership in the request's organization is + dropped with a warning naming them — D2's union wall would otherwise hide the + request from someone already routed to, so the node's existing + `onEmptyApprovers` policy takes over instead of leaving an unopenable task. + + Nothing changes for an approver without `organization`: same resolution, same + queries, no extra reads. + +- 7fb436c: Multi-organization operation is an ENTITLEMENT again: the `group` posture no + longer activates without the enterprise runtime (ADR-0105 D12 correction). + + The first ADR-0105 wave read D12 as "the `group` wall ships open" and made the + posture self-activating — it never probed for `@objectstack/organizations`. That + turned `group` into a free multi-org path around the `isolated` gate (ADR-0081 + D2), and made the weaker isolation the free one, which is not a boundary anyone + would draw on purpose. + + The distinction that was missed: **open code is not free activation.** The wall's + implementation has always lived in the open packages — that is equally true of + `isolated`, whose Layer 0 wall sits in `plugin-security` and is gated on a + service the enterprise package registers. Cloud ADR-0016's 铁律 + (强制免费、治理收费) guarantees that a deployment RUNNING a multi-org shape is + safe; it is satisfied by REFUSING to run one unwalled, not by giving the posture + away. + + ## Changes + + - **`tenancy-service`**: `group` probes `org-scoping` exactly like `isolated`. + Without it the posture resolves to `single` and reports `degraded`. + - **`os serve`**: the ADR-0093 D5 boot guard keys off the resolved POSTURE + instead of `OS_MULTI_ORG_ENABLED`. Previously `OS_TENANCY_POSTURE=group` skipped + both the enterprise package load AND the fail-fast, silently degrading to an + unwalled deployment — the exact ADR-0049 class that guard exists to close. A + `group` request without the runtime now refuses to boot unless + `OS_ALLOW_DEGRADED_TENANCY=1`. + - **New seam — the runtime declares what it entitles.** `org-scoping` may expose + `supportedPostures` (`OrgScopingEntitlement`, `@objectstack/spec/security`); + the open side honours it and fails closed on anything not listed. Whether + `group` and `isolated` are one commercial tier or two is packaging policy, and + packaging policy belongs to the commercial runtime rather than hard-coded in + open core. Omitting the field entitles every walled posture, so existing + runtimes are unaffected. + - **`organization_id` stamping returns to the enterprise runtime.** The previous + wave moved auto-stamping into the open engine; that removed the closed + package's only load-bearing runtime duty, so a five-line forged `org-scoping` + registration would have produced a fully working multi-org deployment. With + stamping back where it was, a forged registration yields NULL-org rows the wall + hides — a broken deployment, not an unlicensed working one. + + **Write-side VALIDATION stays open and is unchanged**, including the + bulk-insert coverage: rejecting a forged `organization_id` is a security + property, not a packaging one. Only filling an ABSENT value moved back. + + - Default-organization bootstrap returns to `single`-only; every walled posture + keeps its existing owner (ADR-0081 D1). + + ## Note for operators + + `OS_TENANCY_POSTURE=group` without `@objectstack/organizations` installed now + **refuses to boot** rather than running single-org. This only affects + deployments that adopted `group` between the two waves. + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 6fdc5c6: feat(client,spec): `ai.agents.*` and `ai.pendingActions.*` — the AI routes the SDK could not reach (#3718) + + #3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted, + then expressed the surface that does exist. It expressed **one** builder's worth + of it. `service-ai` mounts seven; the audit that widened its ledger + (objectstack-ai/cloud#903) counted **ten** routes the SDK cannot reach, nine of + which had simply never been counted. + + This closes the six with the strongest evidence: `objectui` already ships + product on them, over URLs it builds by hand because there was nothing to call. + + **`ai.agents`** — `/ai/chat` talks to the environment's default agent; these + talk to one you name. + + - `agents.list()` — the agents this CALLER may chat with. The route filters by + the caller's permissions (ADR-0049), so an empty list is a legitimate answer + for a seat-less user, not an error to retry. + - `agents.chat(name, request)` / `agents.chatStream(name, request)` — one route, + two methods, mirroring `ai.chat` / `ai.chatStream` rather than inventing a + third shape for the same endpoint. `chat` forces `stream: false` for the same + reason `ai.chat` does: the route streams by default, so leaving the flag to + the caller means the JSON path is the one you have to remember. + + **`ai.pendingActions`** — the human-in-the-loop approval queue. When a tool call + needs a human decision the turn parks an action instead of executing it, and an + app embedding the chat has to render and resolve that queue. + + - `pendingActions.list(options?)` — `status`, `conversationId` and `limit` only. + `AIService.listPendingActions` also accepts `objectName`, but the route never + forwards it; typing it here would offer a filter that silently does nothing. + - `pendingActions.get(id)` + - `pendingActions.approve(id)` — approves **and executes**. Check the returned + `status`: a tool that fails after approval comes back + `{ status: 'failed', error }` with HTTP 200, because the approval succeeded + even though the execution did not. Code that reads only `res.ok` reports a + failed write as a success. + - `pendingActions.reject(id, reason?)` — executes nothing. + + Reads and decisions are separately permissioned server-side (`ai:read` vs + `ai:approve`), so a caller that can list the queue may still be refused on + approve. Handle the 403; one does not imply the other. + + **Typed from what the routes return**, not from what a client might like them + to — the failure #3718 exists to punish. The pending-action shape is the + persisted row, `snake_case` on the wire because that is what it is. Agent rows + require `capabilities`, because that object is what tells a UI which + affordances to render. + + The capstone (#3642) exempts `/api/v1/ai/` by prefix and says the evidence lives + on the other side of the repo boundary. It does: cloud's ledger drives every + `ai.*` method against the tables its builders really return — and since #903 + that means all seven builders, which is what makes these six routes checkable + at all. Their routes come from `buildAgentRoutes()` and + `buildPendingActionRoutes()`, neither of which the ledger could see when the + exemption was written. + +- 259af21: feat(spec,lint): ADR-0109 Phase 1 — platform tool-name registry + advisory `skill.tools[]` reference lint (#3820 R7) + + ADR-0109 (revised) settles the AI tool authoring model: **the default + third-party path needs no tool records at all.** A skill's `tools[]` names + either a platform-registered tool or a tool the runtime materialises from the + app's own declarative actions (`action_`) — the executable, its authz, + and its audit trail stay on the action/flow the app already ships. Tool + records are demoted to an optional AI-presentation refinement layer (Phase 2, + gated on acceptance). + + Phase 1, shipped here: + + - **`PLATFORM_PROVIDED_TOOL_NAMES`** (`@objectstack/spec/system`) — curated + registry of every statically-named tool the cloud AI runtime registers, + grouped by owning package, plus `PLATFORM_TOOL_FAMILY_PREFIXES` for the + materialised `action_` family and `isPlatformProvidedToolName()`. The + `PLATFORM_PROVIDED_OBJECT_NAMES` precedent, applied to tools; conformance + tests live in the owning cloud packages. + - **`validate-ai-tool-references`** (`@objectstack/lint`) — the #3820 R7 + `skill.tools` branch, wildcard-aware, resolving against declared + `stack.tools` ∪ the registry ∪ the materialised action family. Severity + **warning** (ADR-0078 advisory-first ratchet): the registry cannot see + third-party runtime plugins. Joins `REFERENCE_INTEGRITY_RULES`, so + `validate`, `lint`, and `compile` all pick it up. On the HotCRM corpus it + reports exactly the 10 fictional tool references (0 false positives on the + 6 that resolve). + - **`composeStacks` no longer drops `tools`** — the slot joins the + concatenated array fields, so a declared record survives composition. + - `stack.tools` / AI-slot docs updated to the ADR-0109 model. + +- 587fc91: feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt + + The analytics→engine bridge now forwards the request's `ExecutionContext` to + `engine.aggregate`, so the engine's own middleware chain scopes analytics reads + independently of the analytics layer's `getReadScope`. + + **Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing + forced the bridge to pass it — and it did not. An authenticated aggregate + reached the engine with no principal, plugin-security's principal-less fall-open + skipped its RLS injection, and the only thing left scoping the query was the + strategy remembering to call `getReadScope`. #3597 was a strategy that did not, + and both belts were off at once. + + `getReadScope` stays: the two resolve scope through different paths (engine + middleware vs `security.getReadFilter`), and a deployment without + plugin-security has only the analytics layer. This is depth, not a replacement. + + - `StrategyContext` gains `context?: ExecutionContext`, bound per call by + `AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`. + - `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` / + `AnalyticsService` `executeAggregate` config options gain `context?: +ExecutionContext`. **Custom bridges should forward it** to their engine; the + built-in auto-bridge does. Purely additive — an existing bridge that ignores + it keeps working exactly as before. + - `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain + an optional trailing `context`, beside the `scope` / `resolveScope` that + #3639 added — the same two-belt split as the aggregate path. + - `BootOptions.analytics` (`@objectstack/verify`) overrides the + AnalyticsServicePlugin instance, so a gate can boot with the analytics belt + off and assert the engine-side belt alone still scopes. + + **Also fixed on the same seam:** + + - `fetchRecordLabels` — the dimension display-label lookup — is row-granular + (one row per record, real display names). #3639 gave it the analytics-layer + belt (the referenced object's own read scope); it now also carries the + context, so the engine scopes the same read independently. + - `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the + `/analytics/sql` preview read as an unscoped table scan while the real + aggregate was scoped. It now renders the caller's filters and the read scope. + The preview never executed, so this was misleading output rather than a leak. + +- ad4af62: feat: single-source API-method derivation — the server is the only adjudicator (#3391) + + An object's effective API surface is now resolved from **six primitives** + (`get/list/create/update/delete/bulk`) by ONE derivation table in + `@objectstack/spec/data` (`resolveEffectiveApiMethods` / `isApiOperationAllowed` + / `effectiveOperationsArray` / `API_METHOD_DERIVATION`). Every gate consumes it: + the REST data surface, the runtime HTTP/MCP dispatcher, and the + `/me/permissions` annotation. The `apiMethods` whitelist is three-state — + `undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and + the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/ +export`) are DERIVED from the primitives, never declared standalone. (This + release also ships the enum shrink — see the `#3543` changeset: the authored + enum IS the six primitives, and a stored legacy value is stripped at parse + with a warning rather than honored.) + + **Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create, + update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot, + always on this phase); `aggregate`/`search` ⊆ list (search also needs + `searchable`); `history` ⊆ get ∧ `trackHistory`; `upsert` ⊆ create∧update; + bulk sub-ops ⊆ bulk ∧ derived(child). `restore`/`purge` do not derive (the + `enable.trash` flag was retired, #2377). + + **New response-side contract:** `EffectiveObjectPermissionSchema` extends + `ObjectPermissionSchema` with an optional `apiOperations` array; + `GetEffectivePermissionsResponse.objects` uses it, and `/me/permissions` now + hands down the per-object effective operation set. The authoring + `ObjectPermissionSchema` is deliberately NOT extended — the frontend consumes + the effective set the server resolves, never the raw whitelist. + + **Behavior changes (tightening — a `declared ≠ enforced` gap closed):** + + 1. `apiMethods: []` + `apiEnabled: true` now denies every operation (405), + matching the documented three-state contract instead of the prior fail-open + "no restriction". In-repo impact is zero (every `[]` object also sets + `apiEnabled: false`, so 404 precedes 405). + 2. The runtime dispatcher / MCP whitelist is now live. It previously read the + flat shape while `getObject()` returns the flags nested under `.enable`, so + the gate never fired — a silent dead gate now enforced (nested-first, + flat-compatible). + 3. `import`/`export` reverse-derive: an object with a plain CRUD whitelist (no + explicit `import`/`export`) now admits import (⊆ create∨update) and export + (⊆ list). Row-level FLS is shared with list; the export column header is now + projected to the FLS-readable set so it can never expose a wider column set + than list (previously a masked column leaked its name as an empty column). + 4. The bulk surfaces (`createMany`/`updateMany`/`deleteMany`, per-object + `/batch`, cross-object `/batch`) now require the `bulk` primitive AND the + child write (`bulk ∧ child`). The four in-repo explicit-whitelist objects + (`sys_user`, `sys_user_preference`, `sys_business_unit`, + `sys_business_unit_member`) gained `bulk`; a third-party object with an + explicit write whitelist that omits `bulk` will now 405 on the Many/batch + routes. + 5. The 405 body's `allowed` array is now the derived EFFECTIVE operation set + (enum-ordered), not the raw whitelist. + +- d44dbfa: feat(spec)!: shrink the `ApiMethod` enum to the six primitives — legacy values are stripped at parse, never honored (#3543, P2 of #3391) + + **BREAKING** (the `!` marker and this changeset are the breaking-change + record; the train ships as the v17 major — see the `v17-rc-anchor` changeset): + the authored `enable.apiMethods` enum is now exactly the six + primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`). The eight + legacy values (`upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, + `import`, `export`) are no longer authorable — they are DERIVED effective + operations, resolved by the server's single derivation table. + + **Migration (FROM → TO).** Replace each legacy value with the primitives it + derives from, then de-duplicate; if the result names all six primitives, delete + the `apiMethods` key entirely (equivalent to default-open, and it tracks future + primitives): + + | FROM (legacy) | TO (primitives) | why | + | ------------- | -------------------- | ---------------------------------------------- | + | `upsert` | `create`, `update` | upsert ⊆ create ∧ update | + | `import` | `create`, `update` | import ⊆ create ∨ update (writeMode-precise) | + | `export` | `list` | export ⊆ list | + | `aggregate` | `list` | aggregate ⊆ list | + | `search` | `list` | search ⊆ list ∧ `searchable` | + | `history` | `get` | history ⊆ get ∧ `trackHistory` | + | `restore` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + | `purge` | _(delete the value)_ | never derives — `enable.trash` retired (#2377) | + + Reporter codemod: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` + (scans, reports the exact replacement per site, and flags whitelists the + mapping would WIDEN so the edit stays reviewable). + + **Stored metadata keeps parsing — permanent tolerance, narrowing only.** Real + metadata does not upgrade in lockstep with the spec, so a stored legacy value + is NOT a parse error: `stripLegacyApiMethods` (new export) strips it with a + FROM→TO warning (canonicalize-and-warn). Stripping only ever NARROWS exposure — + the derivation table still grants every legacy verb that derives from the + primitives you declared. Two cliffs to know: + + 1. A whitelist of ONLY legacy values (e.g. `['upsert']`) strips to `[]` = + **deny-all** — the object's API closes instead of widening. The strip + warning and the objectql registration diagnostic both call this out. + 2. A legacy value NOT derivable from your declared primitives (e.g. + `['get', 'export']` — export needs `list`) was honored by the P1 + "explicit wins" path and is now denied. Declare the underlying primitive. + + **Type split — authored vs effective vocabulary.** `ApiMethod` (authored) is + now six values; the NEW `ApiOperation` type / `ApiOperationSchema` / + `API_OPERATION_ORDER` (fourteen values, byte-stable pre-shrink wire order) + carry the EFFECTIVE vocabulary. The wire contract is unchanged: the 405 + `allowed` array and `/me/permissions` `apiOperations` still serialize derived + verbs (`export`, `search`, …), and `EffectiveObjectPermissionSchema.apiOperations` + now validates against `ApiOperationSchema`. `EffectiveApiMethods.explicitLegacy` + is removed (nothing is honored verbatim anymore); `API_METHOD_ORDER` remains as + a deprecated alias of `API_OPERATION_ORDER`. + + **Fail-closed tightening (#3545):** a PRESENT but non-array `apiMethods` (only + producible by a raw/out-of-band metadata write) now resolves to `deny-all` + instead of unrestricted — a policy that exists but cannot be read fails CLOSED. + + **Published JSON Schema diverges deliberately:** `data/ApiMethod.json` is the + strict six-value enum (a `z.preprocess` is not representable in JSON Schema), + so external JSON-Schema validators reject legacy values that the zod parse + would strip-and-warn. Treat the JSON Schema as the authored contract; the zod + tolerance exists for stored metadata. + + **objectql:** the P1 "explicit wins" transition is reclaimed — + `warnDeprecatedExplicitApiMethods` is replaced by `warnStrippedLegacyApiMethods` + (a permanent per-object diagnostic for schemas that reach the registry without + passing through Zod; the parse-time strip warning carries no object name). + + **platform-objects:** whitelist audit — `sys_business_unit`, + `sys_business_unit_member` (P1's explicit `import`/`export` reclaimed) and + `sys_user_preference` dropped their `apiMethods` entirely (each named all six + primitives = default-open). Read-only and deny-all whitelists are unchanged; + the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside + `apiEnabled: false`. + +- 474fe39: feat(approvals): declare approver value bindings; retire `queue` approver authoring (#3508) + + - `@objectstack/spec` exports `APPROVER_VALUE_BINDINGS` — the single declaration of how a + designer must source each approver row's `value`: `user`/`team`/`department`/`position` + are DATA-record lookups on the system directory objects (`sys_user` / `sys_team` / + `sys_business_unit` / `sys_position`; `position` commits the machine **name**, the + others the row id), `org_membership_level` is a closed enum (`ORG_MEMBERSHIP_LEVELS`), + `manager` is auto-resolved, `field` names a trigger-object field, and `queue` is + unsupported. Also exports `NON_AUTHORABLE_APPROVER_TYPES`. + - `queue` approver type is deprecated-for-authoring: it still parses (stored flows keep + loading and rendering) but is published in `xEnumDeprecated`, so designers stop + offering it — the runtime has no queue resolution and the slot routes to nobody. The + approver `value` xRef now also maps `manager`, so designers can render its + auto-resolved state. No authored key is removed; nothing to migrate. If a flow carries + `{ type: 'queue' }`, replace it with `team` / `department` / `position` (or a concrete + `user`) until a real ownership-queue implementation lands. + - `@objectstack/plugin-approvals` now warns at resolution time when a stored `queue` + approver is skipped. + - `@objectstack/lint` adds `approval-approver-type-unsupported` (warning) for approver + types that are declared but not implemented by the runtime. + +- a6c3f38: feat(approvals): expose the pending node's `lockRecord` policy on the request row (#3814, objectui#2902) + + An approval node declares `lockRecord` (default `true`), and the record-lock + `beforeUpdate` hook enforces exactly that: `lockRecord: false` and the record + stays writable for the whole time the node waits. The behavior was correct and + has been since Phase B — but it was **invisible to every client**. + + `rowFromRequest` parses `node_config_json` and projects a whitelist out of it + (`__flowLabel`, `__nodeLabel`, `__round`, `escalation.timeoutHours`, + `decisionOutputs`). `lockRecord` was never in that list, and no other field on + `ApprovalRequestRow` carried the lock either. So the strongest thing a console + could learn from `GET /approvals/requests` was _"a pending request exists"_ — + from which it can only assume the record is locked. + + That assumption is wrong on every opted-out node, and a flow that chains nodes + with different policies makes it visibly wrong: the same UI state renders for + "you may edit this" and "the server will reject your save with `RECORD_LOCKED`". + The console has no third option — guessing the other way would offer an edit + that dies on save. + + `ApprovalRequestRow` now carries **`lock_record: boolean`**, read from the same + snapshot the hook reads, with the same `!== false` default. Present on every + service read (`openNodeRequest` / `getRequest` / `listRequests`), so the flag a + client renders and the rule the server applies cannot drift. + + Additive and backward compatible — nothing to migrate. A client that wants + node-accurate lock state reads `request.lock_record`; treat `undefined` (an + older backend) as locked, which is the pre-existing behavior. + + The showcase's `showcase_budget_approval` now declares `lockRecord: false` on + its single-approver Manager Review and keeps `true` on the multi-approver + Executive Review, so both policies are exercised in one flow. + +- 0f8ad09: feat(spec)+fix(approvals): publish approver value data sources, order the type enum for authors, stop silent dead approver slots (#3508 / #3807 follow-ups) + + Four follow-ups from browser-verifying the #3508 approver work end to end. + + **`APPROVER_VALUE_SOURCES` — the designer stops guessing where candidates live.** + `xRef.map` only ever named a picker KIND (`'team'`), never where that picker's + rows come from, so the designer carried its own copy of the data contract — and + the first copy was wrong: every directory kind was wired to `GET +/api/v1/meta/:type`, the metadata REGISTRY, which does not hold `sys_user` / + `sys_team` / `sys_business_unit` / `sys_position` rows. Candidates came back + empty and the control degraded to free text (#3508). The binding is now + projected onto the published JSON schema as `xRef.sources` — `{ source: 'data', +object, valueField }` for the record-backed kinds, the closed enum inline for + `org_membership_level` — derived from `APPROVER_VALUE_BINDINGS` so the two + cannot drift, and inheriting its `satisfies` exhaustiveness (a new + `ApproverType` member that declares no source is a compile error). Presentation + — which field to show, whether to open a people-picker, what subtitle to use — + stays a renderer decision. + + **`ApproverType` declaration order is now the authoring recommendation.** + objectui#2834 argued for leading with indirect bindings and shipped that order + in its own options array — which the Studio inspector never reads: it derives + the picker from this enum via the published schema, so `user` still came first. + The intent only takes effect if the enum carries it, so the enum now reads + `manager, position, department, team, field, expression, org_membership_level, +user` (deprecated `role` / `queue` still parse and stay out of every picker via + `xEnumDeprecated`). Binding one specific person is the least portable choice an + author can make — it breaks when the flow moves to another environment (that id + does not exist there) and again when that person leaves. + + **A graph approver that expands to nobody no longer does it in silence.** + `queue` already warned (#3508); every OTHER graph type — `team`, `department`, + `position`, `org_membership_level`, `manager` — fell back to the same + unactionable `type:value` literal without a word. That silence is what let + #3807 hide for as long as it did: the request opened with an empty slate and + the first symptom was a permanently stuck approval (#3424). The fallback stays + (15.x slots and substring fixtures depend on it); it now logs the type, value + and organization that produced it. `user` / `field` stay quiet — they take the + id they were given and never had an "expanded to nobody" state. + + **`plugin-sharing`'s identical org scope is pinned by tests.** + `BusinessUnitGraphService.orgScope` has the same strict `organization_id` + equality #3807 fixed in approvals. It is unreachable today — every materialized + `sys_sharing_rule` carries `organization_id = null`, so the filter is skipped — + and widening an authorization path on a defect that cannot currently fire is + not a change to make blind. New tests lock both the reachable paths and the + divergence itself, so if sharing ever adopts the null-org=env-wide reading it + is a deliberate edit to a named test rather than a silent behaviour change. + +- 57a3bb3: fix(automation,approvals): the run-resume route is gated by the node the run is parked on (#3801) + + `POST /api/v1/automation/:name/runs/:runId/resume` forwarded a caller-supplied + `{ inputs, output, branchLabel }` straight into `AutomationEngine.resume`, and + `resumeInternal` validated **machine state only** — the concurrent-resume latch, + the run exists, the flow exists, the suspended node still exists. Nothing asked + _who was calling_. + + Approval nodes suspend and resume through exactly that mechanism. So a resume + carrying `branchLabel: 'approve'` walked the approve edge with **no approver + check, no `sys_approval_action` row and no status mirror** — the + `sys_approval_request` row and the run then disagreed permanently. The only + thing standing between the route and the approvals rules was convention; the + showcase spelled it out in a comment ("decide via the approvals API, never a raw + engine `resume`"), and a comment in an example is not an access control. + + Removing the route was not the fix: it is load-bearing for **screen flows** — + the UI flow-runner posts `{ inputs }` there to advance a paused `screen` node. + The gate therefore keys on **what the run is parked on**: + + - `ActionDescriptor.resumeAuthority` (`'any'` | `'service'`, default `'any'`) — + a pausing node declares who may continue it. `approval` declares `'service'`. + - The engine refuses a `'service'` suspension unless the signal carries + `RESUME_AUTHORITY_SERVICE` (`@objectstack/spec/contracts`), a **symbol** the + owning service stamps in-process — a JSON body can never produce one, so the + transport cannot forge it. `ApprovalService` stamps it on the tail of a + decision it has already authorized and recorded. + - The gate follows a **subflow** pause down to the child the signal would + actually reach, so resuming the parent is not a way around it. + - Refusal returns `{ success: false, code: 'forbidden' }` and the route answers + **403**. Nothing is consumed — the request stays pending and the run stays + parked, so the real decision still lands. + + `screen` and `wait` pauses are unchanged, as is every path that already went + through the approvals API. What changes for consumers: + + - **FROM:** finishing an approval with + `client.automation.resume(flow, runId, { branchLabel: 'approve' })` + **TO:** `client.approvals.approve(requestId, …)` (or `.reject` / `.recall`). + The old call now answers 403 and changes nothing. + - Registering your own pausing node whose continuation belongs to a service + rather than to whoever holds the run id? Declare `resumeAuthority: 'service'` + on its descriptor and stamp `RESUME_AUTHORITY_SERVICE` on the signal from that + service. + + A suspension now records the node type that produced it + (`SuspendedRun.nodeType` / `sys_automation_run.node_type`), captured at suspend + time so a flow republished mid-pause cannot re-type the node out from under the + gate; rows written before this fall back to the flow definition. + +- db02d47: **BREAKING** `ChartInteraction` drops `zoom` and `clickAction`; `stepSize` / `description` / `height` are delivered (issue #3752) + + The tail of the declared-≠-delivered sweep from #3729. Five `ChartConfig` props + reached the renderer and did nothing; each got the ADR-0078 call — honor it, or + remove it. Three were honored (objectui#2885), two are removed here. + + **Removed — `ChartInteraction.zoom` and `ChartInteraction.clickAction`.** Both + were redundant against something the platform already delivers, which is why + neither had a consumer anywhere in the framework, the console, the showcase, or + the skill corpus: + + - `zoom` had no renderer primitive behind it, and `brush` already narrows a + range. **Migration:** `interaction: { brush: true }`. + - `clickAction` competed with two click owners that _do_ work — `drillDown` + (opens the filtered records, which is what a segment click is almost always + for) and, in the react tier, the host's own `onSegmentClick`. A third, silent + owner only invited authors to wire a click that never fired. + **Migration:** `drillDown`, or handle the click in React. + + `ChartInteraction` is now `{ tooltips, brush }` — both honored. This follows the + #1475 precedent: trim what cannot be cleanly delivered, implement the rest, and + leave nothing declared-but-inert in between. + + **Delivered — `ChartAxis.stepSize`, `ChartConfig.description`, `ChartConfig.height`** + (objectui#2885). `description` and `height` join ``'s published + `dataProps` now that they do something; `stepSize` rides along inside + `xAxis`/`yAxis`. Their schema descriptions say what they actually do rather than + restating their names. + + Breaking, but shipped as `minor` per the launch-window convention (see + `scripts/check-changeset-no-major.mjs`). Off-spec `zoom`/`clickAction` keys are + stripped by Zod rather than rejected, so no stored metadata fails to parse — the + break is at the TypeScript type level for anyone constructing a + `ChartInteraction` in code. + +- 0bfdf46: fix(spec,cli): conversion deprecation notices reach the author, not just `os validate` (#3855) + + The ADR-0087 D2 conversion layer rewrites an old-shape key to its canonical + spelling at load and emits a structured `ConversionNotice` for each rewrite. The + conversion being silent about _fixing_ the shape is the point — zero consumer + action. Being silent about having **had** to is not: the notice is the one signal + that says _this spelling retires in protocol N, and your metadata stops loading + then_. + + Two of the three surfaces that run the conversion pass discarded every notice: + + | Surface | Before | After | + | ------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- | + | `os validate` | passed a sink, printed them | unchanged | + | `os build` / `os compile` | **passed no sink — notices discarded** | prints them, and includes a `conversions` array in `--json` under the same key `os validate --json` uses | + | `defineStack` | **passed no sink — notices discarded** | warns on the console, once per distinct conversion site | + + This is the #3782 parity class one layer down: not "does this command run the + gate" but "does it listen to what the gate says". Five conversions are live + today (protocol 11 and 15), so an author on any of those shapes was told by one + command and not the other two — and `defineStack` is where that author actually + is, since it runs inside their own config module. + + `defineStack` surfaces notices in **both** strict and non-strict mode: the + conversion happens on the shared `normalizeStackInput` call before the strict + branch, and `strict: false` does not make the old shape any less retiring. + + A new assertion in `validate-build-gate-parity.test.ts` fails if either command + calls `normalizeStackInput` without a sink, so the gap cannot silently reopen. + + No behaviour change for a stack already on canonical shapes: nothing converts, + so nothing warns. + +- 7c7e246: feat(authz): expose the caller's delegable scope — the read half of the + delegated-administration gate (ADR-0090 D12 / ADR-0105 D8) + + `adminScope` decided writes but could not be READ: `assignablePermissionSets` + lived only inside `delegated-admin-gate.ts`, so a UI offering "place this + person in a unit, with these positions" (the D8 scoped-invitation form) had no + way to narrow its pickers. It would list the whole tree and let the user + discover the boundary by being refused — which turns an authorization gate into + a validator and makes the boundary invisible until it bites. + + `ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as + `GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`: + + - `placeableBusinessUnitIds` — union of the subtrees where the caller may place + people (scopes granting `manageAssignments`); + - `assignablePositions` — positions whose every distributed permission set the + caller may hand out (containment check included); + - `scopes` — the held `adminScope`s with subtrees resolved, for attribution; + - `isTenantAdmin` — unconstrained, with everything enumerated so a consumer + renders ONE uniform picker instead of special-casing. + + Computed by the same helpers the write gate enforces with, so an option this + reports is one `assert()` accepts — a test asserts that agreement directly. It + NARROWS; the gate still decides. + + Strictly self-scoped: no target-user parameter, so it discloses nothing beyond + the authority the caller already holds (unlike `explain`, which has one and + gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with + no delegated authority gets empty lists, and a deployment without + `@objectstack/plugin-security` gets 501. + +- f35cdc5: feat(spec): the deprecated-alias warning now covers all three fold-and-drop aliases (#3743 follow-up) + + #3838 introduced `lintDeprecatedAliases` — the pre-parse pass that reports an + alias the parse is about to consume — with one rule, for `action.execute`. The + issue that asked for it predicted the pass would earn its keep beyond that rule, + and it does: `execute` was never special. The spec has exactly **three** + transforms that fold an alias into its canonical key and then drop it from the + parsed output, and all three share the same failure mode — declare both slots + with different values and one of them is discarded with no signal, invisible to + every downstream check because the parse already erased it. + + Two more rules, same shape, same advisory severity, same two surfaces + (`defineStack` at authoring time; `os build` / `os validate` for stacks that skip + strict `defineStack`): + + - **`field-requiredwhen-conditionalrequired-conflict`** — `FieldSchema` folds + `conditionalRequired` into `requiredWhen` (#3754). The discarded predicate + never gates the field. Covers fields on objects _and_ on object extensions. + Compares the predicate **text**, so a bare string and the + `{ dialect, source }` envelope it lowers into are recognised as the same + predicate and stay quiet. + - **`agent-knowledge-sources-topics-conflict`** — `AIKnowledgeSchema` folds + `knowledge.topics` into `knowledge.sources` (#1891). The discarded list names + RAG sources the agent never recruits from. Compares by **set**, so the same + sources in a different order stay quiet. + + Neither fails the build; both name the two values and give the one-line fix. + + Also corrects `content/docs/ai/agents.mdx`, which documented `knowledge` as + `{ topics, indexes }` and used `topics` in all three examples — teaching the + deprecated alias as if it were the canonical key, and disagreeing with + `skills/objectstack-ai/SKILL.md`, which already had it right. The examples now + use `sources`. + +- c2d9098: feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + + Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already + surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` + #3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did + not — the same strips happened silently on every batched row — and the typed + client warning + CORS mirror were deferred. This closes those out. + + **Bulk passthrough (metadata-protocol).** + + - `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. + - `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. + - `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. + - **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + + **Contract (spec).** `BatchOperationResultSchema` gains an optional per-row + `droppedFields` (covers `updateMany` + `batch`, which alias + `BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional + aggregated `droppedFields`. Both are omit-when-empty, so existing clients are + unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for + batches — one response header cannot express per-row drops, so the per-row body + field is the canonical bulk channel. + + **Typed client warnings (@objectstack/client).** `CreateDataResult` / + `UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body + channel a type instead of an untyped property. + + **CORS (@objectstack/hono, @objectstack/plugin-hono-server).** + `x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` + allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin + browser can read the single-write drop header. The body `droppedFields` remains + the primary, cross-origin-safe surface — this is a convenience mirror. + + **GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, + but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and + `handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There + is no schema generator or mutation resolver to expose a typed payload field on, + so there is nothing to wire until a GraphQL engine lands — at which point the + protocol-layer `droppedFields` is already present and only the GraphQL schema + projection would remain. + +- 9613396: feat(security): ENFORCE the user-level export axis on the server (#3544) + + `allowExport` landed as a spec bit plus a `/me/permissions` annotation, which + hid the client's Export button — and nothing else. Because `export ⊆ list`, the + REST export route streams through `findData` and the engine middleware sees an + ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller + holding `allowExport: false` could still `curl +/api/v1/data/:object/export` and drain the whole table. Declared, not enforced. + + - **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is + now a real decision: `export` = read granted ∧ not explicitly denied. + `allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map + means "the bit must be truthy", which would have denied export to every + permission set authored before the axis existed. The new exported + `resolveUserExportAllowed()` folds the tri-state across sets (`true` beats + `false` beats unset) exactly as the `/me/permissions` merge does. + - **spec** `ISecurityService` gains `canExport(object, context)` — the question a + bulk-egress door outside the engine middleware has to ask before it reads. + Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the + middleware. + - **rest** `GET /data/:object/export` calls it and answers **403 + `EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the + object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405 + says the object exposes no export, 403 says this caller may not use it. No + security service (no `plugin-security` ⇒ no permission sets) → allowed, the + same fail-open posture as every other permission gate in that layer; service + present but unable to answer → denied. + - **plugin-hono-server** the `/me/permissions` annotation now falls back to the + `'*'` entry's export bit when a per-object entry declares none, matching the + evaluator's own wildcard fallback — so a set that denies export wholesale via + `'*'` no longer offers a button the server refuses. + + Backward-compatible: `allowExport` is still an opt-out with no default, so an + unset bit inherits read and existing permission sets behave exactly as before. + Only a permission set that explicitly sets `allowExport: false` changes — and it + now changes on the server, which is the point. + + Implementers of `ISecurityService` outside this repo must add `canExport`; the + interface member is required, matching how `getReadableFields` was added. + Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a + partial implementation degrades rather than throwing. + +- 2fa4ca1: Dynamic approver routing for approval nodes (#3447 P2) — three new declarative capabilities: + + **`expression` approvers.** A new approver type whose CEL expression resolves WHO approves at node entry, over exactly three roots: `current.*` (the record's live state), `trigger.*` (the submit-time snapshot) and `vars.*` (flow variables, incl. upstream node outputs). `record` and bare field names are rejected before evaluation — on this platform `record` always means "the record at event time", which is ambiguous at an approval node — with error messages that prescribe the correct spelling. The optional `resolveAs: 'user' | 'department' | 'position' | 'team'` re-expands each resolved id through the same graph lookups the static types use; with `behavior: 'per_group'` each intermediate value (e.g. each returned department) forms its own sign-off group. A missing key fails the node loudly; only a present-but-empty result counts as an empty slate. + + **`onEmptyApprovers` policy.** What an empty resolved slate does, node-level, for all approver types: `admin_rescue` (default — request opens for privileged takeover, the #3424 behaviour), `fail` (node fails), or `auto_approve` (skip the request, continue down the `approve` edge with `output.autoApproved = true`). To support auto-approve, the automation engine now honours `NodeExecutionResult.branchLabel` on the synchronous completion path — the field existed but was only ever consumed via resume signals. + + **Decision outputs.** `decide(..., { outputs })` hands structured data from the approver to the flow: the author declares allowed keys on the node (`decisionOutputs`), approvers fill values only, and accepted outputs resume the run as `.` variables — a later approval node's expression can read `vars..picked_departments`, closing "the previous approver picks the next step's approvers" without a record-field detour. Undeclared keys reject the decision; `decision`/`requestId` are reserved. Multi-approver tallies now always pin to the open-time approver snapshot (previously unanimous re-resolved at each decision against the payload snapshot). + + Also: `collectCelRootIdentifiers` is exported from `@objectstack/formula` (shared by the new `os lint` rules and the runtime pre-check, so they can never drift), resolution inputs are audited on the request snapshot as `__resolvedFrom`, and three new lint rules gate expressions, empty-slate policies and reserved output keys at author time. + +- f5a2320: fix(field): fold the deprecated `conditionalRequired` alias into `requiredWhen` and drop it from the parsed output (#3754) + + Second instance of the alias-drift shape #3713/#3742 fixed for `action.execute`. + `requiredWhen` is canonical and `conditionalRequired` is its documented deprecated + alias, but `FieldSchema` had **no canonicalization at all** — both keys stayed live + in the parsed output, so every consumer had to re-implement the precedence. That is + exactly the condition that produced #3713, where the server kept `target` while + objectui's renderer preferred the alias and one button ran two different scripts. + + Worse, the alias surviving parse was **test-pinned**, including a case literally + named _"requiredWhen and its alias conditionalRequired can coexist"_ — the inverse + of the contract #3742 had just established one field over. + + `FieldSchema` now lowers `conditionalRequired` into `requiredWhen` at parse time and + removes the alias from its output; `requiredWhen` wins when both are declared. The + pinning tests are inverted accordingly, and a new case asserts the alias is gone + from a field parsed through `ObjectSchema` — the path a renderer actually receives, + not just a bare `FieldSchema.parse()`. + + No live bug is being fixed here: every reader we can see already prefers the + canonical key (`rule-validator.ts` reads `requiredWhen ?? conditionalRequired`). The + point is that nothing in the contract _made_ that right. This is hardening — it + removes the chance rather than a defect. + + `objectql`'s `requiredWhen ?? conditionalRequired` fallback is kept on purpose: + `evaluateValidationRules` is also handed raw, unparsed field definitions, which still + carry the alias. + + **Authoring is unchanged.** `conditionalRequired` is still accepted on input, still + lowered, still listed in the reference docs and JSON Schema. Nothing to migrate in + app metadata. + + **Consumers of the parsed metadata** must read the canonical slot: + + - FROM `parsedField.conditionalRequired` → TO `parsedField.requiredWhen` + - One-line fix: `field.conditionalRequired || field.requiredWhen` becomes + `field.requiredWhen` + + `z.infer` no longer carries `conditionalRequired`, so a stale + reader fails to compile rather than silently reading `undefined`. A new + `FieldParseInput` (`z.input`) names the author-facing shape that + still accepts the alias — distinct from the pre-existing `FieldInput` factory-helper + type, which is `Partial` and unrelated. + +- deb538f: fix(storage): let an object delegate file-read authorization to its service + + Fixes a regression from the governed-download change (ADR-0104 D3 wave 2): a + **legitimate approver could see a decision attachment's filename but got 403 + opening it**, found by driving app-showcase in a browser as a real non-admin + approver. + + Cause: a field-owned file's download was authorized by testing whether the + caller can READ the owning row. For an ordinary business object that is right — + row readability _is_ the access rule. For `sys_approval_action` it is the wrong + authority: the audit table is deliberately closed to ordinary approver + positions (`operation 'find' … is not permitted for positions [auditor, +everyone]`), so the test denied the very approver the attachment was filed for. + The approvals _service_ has always had the real rule, which is why the timeline + listing the attachment returned 200 while the bytes returned 403. + + An object may now name a service to answer the question instead: + + - `ObjectSchema.fileAccessDelegate` — a kernel service that authorizes + downloads of files owned by that object's media fields. + - `IFileAccessDelegate.authorizeFileRead(recordId, context)` — the contract. + - `sys_approval_action` declares `'approvals'`; `ApprovalService.authorizeFileRead` + reuses the _same_ gate `listActions` applies (visibility of the parent + request) rather than inventing a second, looser rule for the bytes. + + **Fails closed**: a declared delegate that is missing or does not implement the + method denies, rather than silently reverting to the raw read it was declared to + replace. Objects without the declaration are unchanged. + + Verified in the browser against app-showcase, both sides of the gate: the + approver now downloads the real PDF (200), and an anonymous request is still + refused (401) — the anonymous capability URL the original change closed stays + closed. A decision attachment ends up exactly as readable as the decision it + hangs off: never more, and no longer less. + +- 0c8a22f: feat(spec): one canonical conformance table for the filter logical combinators + + `FilterCondition` is evaluated by four independent implementations, and nothing + held them to a shared standard: + + | Backend | Where | + | -------------------------- | ----------------------------------------------------------- | + | SQL compiler | `driver-sql` `applyFilterCondition` | + | In-memory matcher | `driver-memory` `memory-matcher` | + | Record-at-a-time evaluator | `formula` `matchesFilterCondition` (RLS write-side `check`) | + | Read-scope SQL lowering | `service-analytics` `read-scope-sql` | + + In #3774 the SQL compiler OR-ed the contents _within_ a `$or` branch instead of + AND-ing them, so every `$or` filter matched more rows than it should. The other + three were correct — but that was luck, not enforcement, and the divergence was + invisible until someone ran a real query. The fix for #3774 left three + near-identical shape tables copied across packages and the fourth backend + unlocked entirely, which is the same drift setup one step later. + + `@objectstack/spec/data` now exports the table itself: + + - `FILTER_LOGIC_ROWS` — a 2x2 truth table over two columns (so a wrongly-OR-ed + pair always shows up as extra ids rather than by luck of the data), plus the + record-scope columns real read scopes are written against. + - `FILTER_LOGIC_CASES` — 17 cases, each a `FilterCondition` and the ids it must + match: keys within a branch, multiple operators on one field, `$and`/`$or`/ + `$not` nesting in both key orders, and the scope shapes that occur in shipped + metadata. + + Each backend now has a thin test that feeds the rows through its own evaluator + and asserts the shared expectations. **Adding a case to the table adds it to all + four at once** — that is the point. + + Two things this bought immediately: + + - `read-scope-sql` — the compiler that lowers RLS read scopes for the analytics + path — is now verified by **executing** its SQL against a real engine and + comparing rows. It was previously only checked by asserting the emitted SQL + string, whose ceiling is the author's own reading of SQL. It passes unchanged. + - The table is a public export, so a third-party driver author can check a new + backend against the same standard. + + **Deliberate scope:** logical combinators only. The predicates are boring on + purpose — string equality, `$in`, `$ne`, `$gte`/`$lt`. Nothing here exercises + null handling, dates, numeric coercion, `LIKE` escaping or case sensitivity, + because those legitimately differ between a SQL engine and a JS matcher; folding + them in would make the table unpassable rather than more useful. A case belongs + in it only if **every** backend must agree. + +- 1d4756e: fix(i18n)!: `/i18n/labels/:object/:locale` emits the entry shape it declares — + and stops discarding `help`/`options` (#3847) + + `GetFieldLabelsResponseSchema` has always declared each label as an object: + + ```ts + labels: z.record( + z.string(), + z.object({ + label: z.string(), + help: z.string().optional(), + options: z.record(z.string(), z.string()).optional(), + }) + ); + ``` + + Both serving surfaces emitted `Record` — a bare label per field. + A client typed against `GetFieldLabelsResponse` read `labels[field].label` and + got `undefined`, because the value was the string itself. The SDK's type was + right the whole time; the servers were wrong. + + The cost is not only the type mismatch. `FieldTranslationSchema` carries `help` + and `options`, bundles populate them, and the endpoint threw them away. objectui + needs exactly those — its `spec-translations.ts` transform reads `label` **and** + `options` (as `fieldOptions...`) — and gets them by pulling the + whole bundle from `/i18n/translations/:locale` and resolving client-side. The + per-object endpoint could not have served it even if it wanted to: the data was + being dropped at the emit site. + + Fixed at that emit site, `resolveObjectFieldLabels`, which both surfaces already + share as of #3833 — so one change covers both. `help` and `options` are attached + only when non-empty: an `options: {}` would claim a field has translated options + and hand back none, and a `help: ''` would erase a caller's source help text. + Fields with no non-empty `label` are still omitted entirely, which is what lets + `ResolvedFieldLabel.label` be a required string. + + **The response schema is unchanged** — this moves the implementation onto the + contract, not the contract onto the implementation. Generated docs are + byte-identical for that reason. + + `placeholder` is deliberately left out. `FieldTranslationSchema` has it and the + response schema does not, so emitting it would be widening the contract rather + than satisfying it — and adding an optional response field later is additive and + non-breaking, whereas guessing now is not. + + The regression guard is the part worth keeping: a test that builds the response + body from the shared helper and parses it with `GetFieldLabelsResponseSchema`. + Nothing had ever put the emitted value and the declared contract in one + assertion, which is precisely why a bare string could sit under an object schema + unnoticed. Third and last of the declared ≠ enforced gaps on this endpoint + family, after #3676 (request filters no server read) and #3833 (a derivation + scanning a retired dialect). + + BREAKING: `labels[field]` is now `{ label, help?, options? }` rather than a + string. No consumer in this repo or objectui read it — objectui never calls this + route, and in-repo use is the SDK method plus URL-shape tests — so the practical + blast radius is nil, and this is the cheap moment to align it. + +- 720c5ad: fix(runtime,i18n): the dispatcher's field-labels route reads the bundle shape + producers actually write — one shared derivation (#3833) + + `GET /i18n/labels/:object/:locale` served through the dispatcher returned + `{ labels: {} }` for every provider. Its derivation scanned for flat + `o..fields.` keys: + + ```ts + const prefix = `o.${objectName}.fields.`; + for (const [key, value] of Object.entries(translations)) { … } + ``` + + That dialect was retired by #3778 — no producer has ever written it, and a real + bundle's top-level keys are the `TranslationData` groups (`objects`, `apps`, + `messages`, …), so the prefix could not match anything. 4cca74c fixed the + identical derivation in `service-i18n` and did not reach the dispatcher's copy. + + This is not a rare fallback. `getFieldLabels` is optional on `II18nService` and + **nothing implements it** — not `memory-i18n`, not `file-i18n-adapter` — so the + dedicated-method branch both surfaces check first is dead in production and this + derivation is the only path there is. Any stack served by the dispatcher (the + AppPlugin in-memory provider auto-registered for stacks declaring translation + bundles) got an empty map, indistinguishable from "this object has no translated + labels": nothing errored, nothing warned. + + Worse than the class it was found next to. #3676, which prompted the check, + ignored a declared filter and returned the full bundle — a correct superset. This + returned nothing and said it was fine. + + The derivation now lives once, as `resolveObjectFieldLabels` in + `packages/spec/src/system/i18n-resolver.ts`, alongside the other resolvers that + read `TranslationData`. Both surfaces call it. Keeping a copy each is precisely + how one got fixed and the other did not; the next bundle-shape change now has one + place to land. Fields carrying no non-empty `label` stay omitted rather than + emitted blank — partial translation is the normal state, and callers merge this + map over their source labels, where a `''` would erase them. + + ### The tests were fiction on both sides + + The dispatcher's fallback test fed flat `o.contact.fields.first_name` keys and + asserted labels came back, so it passed on data that cannot occur while + production returned `{}` — the same failure mode as the client test retired in + #3676, which asserted a query string was built that no server read. It now feeds + the nested shape, and was confirmed to fail against the pre-fix code (`expected +{} to deeply equal { first_name: 'First Name', … }`) rather than merely passing + after it. The shared helper carries its own unit tests, including one pinning + that the retired flat dialect resolves to `{}`. + + The same suite's mock also declared a `getFieldLabels` no shipped provider has, + and returned flat-dialect data from `getTranslations`; both now reflect what a + real provider does, with the divergence noted where it remains deliberate. + + Not addressed here, filed separately: `GetFieldLabelsResponseSchema` declares + `labels` as `Record`, but both surfaces emit + `Record` — a third declared ≠ enforced gap in the same endpoint, + and a wire-shape change too breaking to fold into a correctness fix. + +- 41642b0: fix(runtime,i18n)!: `/i18n/locales` answers in one shape — plus the + success-envelope conformance gate that found it + + Follow-up to #3676 / #3833 / #3847. Those three were each a body that did not + match the schema declaring it, and each survived a green suite because **every + test asserted the emitted body against a hand-written literal**. Comparing + output to a literal proves the code does what the test author believed; it + cannot prove the code does what the contract declares. Nothing had ever put the + emitted value and the declared schema in the same assertion. + + This adds that assertion as a suite — `i18n-success-envelope.conformance.test.ts` + in `runtime`, the missing success-path twin of service-i18n's + `error-envelope.conformance.test.ts` and the same pairing storage got in #3689. + Every `/i18n` success body is parsed against `BaseResponseSchema` and against + the schema `plugin-rest-api` names for that route (`responseSchema: +'GetLocalesResponseSchema'`, …), imported rather than restated. + + **It found a fourth gap on its first run.** `GET /i18n/locales` passed + `getLocales()`'s raw `string[]` straight through the dispatcher, while + `GetLocalesResponseSchema` declares `{ code, label, isDefault }[]` — and + service-i18n, the _other_ provider of this identical route, already emitted + descriptors. One endpoint, two shapes, decided by which plugin mounted it, with + the dispatcher's form contradicting the SDK's own `GetLocalesResponse` type. + + That is the same split #3833 found in the field-labels derivation, one route + over, and it happened for the same reason: two surfaces, one mapping, kept + twice. So the mapping is now shared as `toLocaleDescriptors` in + `packages/spec/src/system/i18n-resolver.ts`, next to `resolveObjectFieldLabels`, + and both surfaces call it. `label` is the locale code — no display-name source + exists in the tree and the schema requires the field; inventing an ICU + display-name table here would be a product decision, not an implementation + detail. + + The gate was verified the same way #3833's was: the fix was reverted and the + suite confirmed to fail on it — + + ``` + locales body does not match its declared schema: + [{"expected":"object","code":"invalid_type","path":["locales",0], + "message":"Invalid input: expected object, received string"}, …] + ``` + + — rather than merely passing once written. Five existing tests pinned the bare + `string[]`; they now assert on `.map(l => l.code)`, so the codes stay pinned + while the shape is owned by the schema. + + BREAKING: `GET /i18n/locales` served by the dispatcher now returns + `[{ code, label, isDefault }]` instead of `['en', …]`. Callers on the + service-i18n mount already received this shape, and the SDK's published + `GetLocalesResponse` type has always described it, so this ends a divergence + rather than starting one. + + Worth generalizing beyond `/i18n`: `plugin-rest-api.zod.ts` already carries a + `responseSchema` name on essentially every route (29 declarations across 28 + handlers), so the route → declaring-schema mapping needed to run this check + repo-wide exists today and is unused. + +- 4cca74c: fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778) + + A translation authored in the product saved successfully and then rendered + nothing. Not a resolver gap — a contract split. The `translation` metadata type + (`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it) + was registered against `AppTranslationBundleSchema`, an object-first shape keyed + on `o.`. Every resolver, `os i18n extract`, `os i18n check`, the objectui + hooks, and all nine shipped bundles read `objects.`. Nothing bridged the + two, so the save path and the read path never met. + + **Why converge instead of bridge.** A converter was the obvious fix and the + wrong one: it would be throwaway code, and it would start producing _working_ + `o.`-shaped rows — closing the migration-free window that exists precisely + because the feature never functioned. The retired shape's real-world footprint + was zero: all three `*.translation.ts` files in the tree (platform-objects, + CRM and todo examples) were already `objects.`-shaped, contradicting the type's + own registered schema. Converging is a registration fix, not a migration. + + **Breaking.** `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema`, and + their types are **deleted** — no deprecation cycle. Nothing worked end-to-end + through them, so there is no functioning consumer to protect, and a + deprecated-but-present schema is exactly the exemplar an AI agent copies into + new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go + with them: zero implementers, so they advertised a capability the runtime never + delivered. + + **The replacement.** `TranslationItemSchema` — one locale of the same + `TranslationData` groups a file bundle uses, plus the `locale` it translates, + with a `defineTranslation()` factory. An item is one entry of a + `TranslationBundle`; that is the whole type. + + Three details are deliberate, all aimed at the failure being silent rather than + loud: + + - **`locale` is required**, not inferred from the item name. The sync skips an + item whose locale it cannot resolve, and a skip is invisible to whoever — or + whatever — authored it. (The name fallback still covers rows written before + this.) + - **Retired keys are rejected, not stripped.** Zod drops undeclared keys + silently, which would reproduce this bug exactly: save succeeds, nothing + renders. A pre-parse guard turns that silence into a 422 naming the group to + use (`'o' … — use 'objects.'`). It runs ahead of the parse so the + retired keys stay out of the schema itself — the generated JSON Schema and the + Studio editor never advertise a shape that cannot work. + - **`ObjectTranslationData.label` is now optional.** Partial translation is the + normal state and every resolver already treats each key as independent. + Requiring it forced authors to restate the source label just to validate, + filling bundles with fake translations that mask real coverage gaps. + + Also in this change: the authored-translation sync warns (naming the row and the + fix) when it meets a row still in the retired shape instead of loading it into + nowhere, and no longer merges publish bookkeeping (`_lockReason`, + `_packageVersion`, …) into the translation layer. `GET +/i18n/labels/:object/:locale`'s fallback now reads the nested + `objects..fields..label` data it is actually given — it scanned for + flat dotted `o..fields.` keys, a third dialect no producer ever + wrote, so it always returned `{}`. + + Migration: author every translation — file or runtime item — under `objects.`. + `o` → `objects`, `app` → `apps`, `nav` → `apps..navigation..label`, + `dashboard` → `dashboards`, `_globalOptions` → + `objects..fields..options`, `_meta.locale` → top-level `locale`, + `_actions.confirmMessage` → `_actions.confirmText`. `reports`, `notifications`, + `errors`, and `namespace` had no runtime consumer and have no replacement. + +- 88ef03e: fix(spec,client)!: `GetTranslationsRequest` is locale-only — drop the + `namespace` / `keys` filters no server ever read (#3676) + + `GetTranslationsRequestSchema` declared two optional filters, and the endpoint + description promised one of them ("...for the specified locale and optional + namespace"). Neither serving surface read either: the dispatcher domain body + (`runtime/src/domains/i18n.ts`) takes `parts[1]` / `query.locale`, and + service-i18n (`i18n-service-plugin.ts`) takes `req.params.locale`. Both return + the locale's whole bundle. The SDK meanwhile put both on the query string, so a + caller who passed `keys` to shrink the response shrank nothing and got no + indication the filter was inert — Prime Directive #10's declared ≠ enforced, the + same shape #1475 trimmed out of the validation-rule types. + + Trimmed rather than implemented, on three counts: + + - **No consumer.** No call site in this repo or `objectui` passed either field. + The docs (`content/docs/api/client-sdk.mdx`, `skills/objectstack-i18n/SKILL.md`) + already documented `getTranslations(locale)` as a full-bundle snapshot, so the + schema was the outlier, not the docs. The one thing that did exercise them was + a client test asserting the query string got _built_ — it pinned the phantom + rather than any behaviour, since no server read what it asserted was sent. It + is replaced here by its inverse: a regression test that the request carries no + filter query at all. + - **`keys` could not deliver what it advertises.** `II18nService.getTranslations` + (`contracts/i18n-service.ts`) takes only `locale`, so a filter could only be a + post-filter over an already-materialized bundle. `keys` reads as a payload + optimization; a post-filter saves wire bytes but none of the server work, and + widening the contract would break every implementer (`memory-i18n`, + `file-i18n-adapter`) for a capability with no caller. + - **`keys` has no defined meaning against the current bundle shape.** Under the + retired flat `o.`-dotted dialect, `keys: ['o.account.label']` was an obvious + pick. #3778 settled the tree on one nested `TranslationData` shape, where a + flat `string[]` is neither a path set nor a group set, and a filtered response + would have to be rebuilt as a sparse nested tree to stay schema-valid. That is + a design decision, and nothing is waiting on it. + + `namespace` is the one that got _easier_ — it now lands exactly on + `TranslationData`'s top-level groups, which is what its own description already + said ("e.g., objects, apps, messages"). It is still trimmed here: re-adding an + optional request field is additive and non-breaking the day the Studio's + per-module views actually need it, whereas shipping an unexercised filter path + now means dead code with tests to match, and a declared-but-unread field is + precisely the exemplar the next author copies. + + BREAKING: the two schema fields and the `getTranslations(locale, options?)` + second parameter are removed with no deprecation cycle. Nothing worked through + them — a passed filter was silently ignored — so there is no behavior to + protect. Runtime impact is nil (the fields were optional and now strip); TS + callers passing them fail to compile, which is the intended signal. + +- 9e2caf3: feat(spec): codify the IHttpServer soft extensions and unmatched-request semantics (#3607, ADR-0076 OQ#10 follow-up) + + Three behaviors every adapter already implements — locked until now only by + the `@objectstack/http-conformance` cross-adapter suite — become formal + contract on the interface: `IHttpResponse.write`/`end` (SSE/chunked + streaming: headers flush on first write, no buffering until end, `end` + required wherever `write` exists), `IHttpServer.getPort()` (real bound port + after `listen()`, incl. ephemeral `listen(0)`), and the unmatched-request + semantics (path-miss → 404 with the shared not-found body; method-miss → + 405 with an `Allow` header). All members optional with feature-detect + guidance — zero behavior change, both adapters already conform; the + conformance assertions now cite the contract instead of merely observing + parity. + +- 394b7a1: feat(job): honor the authored `retryPolicy` / `timeout` in the job scheduler (#3494) + + `JobSchema.retryPolicy` and `JobSchema.timeout` used to be parsed-but-ignored + (the 2026-06 liveness audit's aspirational-config cluster). They are now + enforced end to end — built rather than pruned, since retry/backoff and + per-run time limits are semantics job authors reasonably expect: + + - **spec**: `IJobService.schedule` gains an optional 4th `options` argument + (`JobScheduleOptions` with `retryPolicy` / `timeout`, mirroring the + authorable schema); new `JobRetryPolicy` type. Backward compatible — + existing 3-arg implementations and callers are unaffected. + - **service-job**: new `runWithPolicy` helper (exported, with + `JobTimeoutError`) wraps every handler invocation in `CronJobAdapter` and + `IntervalJobAdapter`; `DbJobAdapter` threads options through to its inner + adapters. Failed attempts (including timeouts) retry with exponential + backoff `backoffMs * backoffMultiplier^(retry-1)` up to `maxRetries`; + an attempt exceeding `timeout` is recorded with execution status + `'timeout'`. No `options` → exactly the legacy single-attempt behavior. + - **runtime**: declarative-jobs registration in AppPlugin forwards the + authored `retryPolicy` / `timeout` to the scheduler. + + Note: JavaScript cannot forcibly cancel an in-flight handler — a timed-out + attempt is abandoned, not killed. The retry delay caps only via the + multiplier arithmetic (no maxDelay knob yet). + + Refs #3494, #1878, #1893. + +- 677b591: feat(spec): `ListColumn` gains `prefix` and the `{ type, field }` `summary` form (objectui#2231) + + Two list-column capabilities the ObjectUI grid renderer has shipped for a while + were missing from the protocol, so they lived on as a local `.extend()` in + `@object-ui/types` — the exact fork-shaped drift objectui#2231 is closing. Both + are now spec-owned: + + - **`prefix`** (`ColumnPrefixSchema`) — Airtable-style compound cells: render a + second field inline before the cell value (e.g. a status badge in front of the + record name), so a list carries two signals in one column. + `{ field, type?: 'badge' | 'text' }`, `type` defaulting to `'text'`. + - **`summary` object form** (`ColumnSummaryConfigSchema`) — `{ type, field? }`, + for a footer that aggregates a field OTHER than the column's own (an `amount` + column summing `amount_in_base_currency`). The shorthand `summary: 'sum'` is + unchanged and remains the common case. `type` reuses `ColumnSummarySchema`, so + both forms share one aggregation vocabulary and cannot drift apart. + + Additive and backward compatible: every previously valid `ListColumn` still + parses. New exports: `ColumnPrefixSchema` / `ColumnPrefix` and + `ColumnSummaryConfigSchema` / `ColumnSummaryConfig`. + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- 2a5f04a: `` aggregate result-column naming is now a contract, and its axis bindings are validated (issue #3701) + + Split out of #3583 Phase 2 (#3684), which extended ADR-0021 axis checking to + report charts, list-view charts, and dataset-bound page chart components but had + to leave the react `` block out: it is OBJECT-bound (`objectName` + + an inline `aggregate`), `aggregate` existed in the contract only as the + description string `'{ field, function, groupBy }'`, and nothing in the repo said + what the aggregated result columns were called. Without that, `xAxis`/`yAxis` had + nothing to resolve against, and guessing a convention would have manufactured + false positives (ADR-0072 D1). + + **The convention, recorded rather than invented.** Every path that can serve an + object-bound chart already agreed — the engine's structured-`groupBy` aggregate + (whose alias objectui sets to `field || function`), the legacy analytics query + (which remaps its measure key back to `field`), the client-side fallback, and the + console's own chart-view wiring (`xAxisKey: groupBy`, `series[].dataKey: field`). + `packages/spec/src/ui/chart-aggregate.ts` writes it down and exports it: + + - an object-bound aggregate returns rows keyed by the **raw field names** — + `groupBy` for the category column, `field` for the value column, the literal + `count` for a fieldless count, plus `__comparison` under a comparison + overlay; + - `chartAggregateCategoryKey` / `chartAggregateValueKey` / `chartAggregateResultKeys` + derive those columns so producers and checkers cannot re-derive them apart; + - `ChartAggregateSchema` replaces the description string with a real Zod schema + and rejects a non-`count` function with no `field` (which used to reach the + renderer as `sum(undefined)` and render blank). + + This is the deliberate opposite of the dataset path, whose rows are keyed by the + declared measure `name` (`sum_amount`) — the trap `chart-measure-unknown` catches. + Only the dataset path has an author-chosen name to key by. + + **``'s contract now names the props it actually reads.** The block + consumes `xAxisKey` and `series[].dataKey`; `ChartConfig`'s `xAxis`/`yAxis`/`series` + shapes reached it and were silently dropped, which ADR-0078 forbids. They are + removed from the block's `dataProps`; `chartType`, `xAxisKey`, and `series` are + declared in the React overlay where the other bindings live. + + **`validate-react-page-props` now reads attribute VALUES**, not just names, for + ``: + + - `react-chart-field-unknown` (error) — `aggregate.field` / `aggregate.groupBy` + naming a field the bound object does not declare; + - `react-chart-aggregate-invalid` (error) — an unimplemented aggregation + function, or a non-`count` function with nothing to aggregate; + - `react-chart-axis-unknown` (error) — `xAxisKey` / `series[].dataKey` naming a + column the aggregate does not return (including a dataset-style `sum_total`), + or a category axis bound to the value column; + - `react-chart-axis-inert` (warning) — the `xAxis` / `yAxis` shapes this block + never reads. + + Value reading is opt-in per block and evaluates only static literals: a prop + driven by React state or a variable, a usage carrying a `{...spread}`, a chart + given inline `data`, and objects another package defines are all skipped + silently — an unresolvable binding is not a wrong one. + +- 4f740b0: ``'s author contract is the spec `ChartConfig` shape again (issue #3729) + + #3701 trimmed `xAxis`/`yAxis`/`series` out of the `` contract + because the renderer read `xAxisKey`/`series[].dataKey` and silently dropped the + ChartConfig shapes — an honest record of the runtime gap, not the target state. + objectui#2880 closed the gap the other way round (the renderer now honors + `ChartConfig` through one normalization boundary), so the contract follows the + protocol again (ADR-0082 D1: the spec schema IS the protocol). + + **Contract.** `type`, `xAxis`, `yAxis`, `series`, `subtitle`, `showDataLabels`, + `annotations` and `interaction` are published from `ChartConfigSchema`; the + internal `chartType`/`xAxisKey`/`series[].dataKey` spellings leave the author + contract. `annotations` and `interaction` gained the `.describe()` they never + had, so the generated contract stops publishing bare `object[]` with no meaning. + + **The `type` exception.** `ChartConfig.type` is the chart family, but on any + surface that flattens chart config into a props bag `type` is already the SDUI + envelope's component discriminator — an author writing `type="bar"` used to + replace `object-chart` and the block stopped resolving. The collision is created + by the flattening and is resolved there (objectui's react-page wrapper), so the + contract can publish `type` as the spec spells it. The contract generator's + blanket `type` skip is now overridable by an explicit `dataProps` allow-list, + since for this one block `type` is a real author prop. + + **Lint.** `validate-react-page-props` reads the axes in the spec spelling — + `xAxis.field`, `yAxis[].field`, `series[].name` — and keeps accepting the + internal spellings silently, because dashboards and the console's own chart-view + wiring emit them. `react-chart-axis-inert` is retired: the props it warned about + are honored now, so the warning would be false. The three binding-integrity + rules from #3701 are unchanged. + + **Spec.** `chart-aggregate.ts` records the constraint the whole result-column + convention rests on: an inline `aggregate` is SINGLE-MEASURE. Keying rows by the + raw field name only works because there is exactly one measure to key; two + measures over one field would collide, and resolving that needs an author-chosen + name per measure — which is what a dataset is. Widening `ChartAggregateSchema` + into a measures array would silently invalidate every axis binding these rules + validate, so the boundary is now written down rather than left to be rediscovered. + + The chart taxonomy note is corrected too: grouped/stacked bar and stacked area + are absent from `ChartTypeSchema` not because they render as their base chart, + but because stacking is a property of the SERIES (`ChartSeries.stack`), not a + chart family — one `bar` family plus a series stack group expresses all three. + `ChartInteraction.zoom` is now marked declared-not-delivered in its own + description rather than reading as shipped. + +- 67452d1: feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589) + + Custom system pages authored as metadata (Installed Apps, Cloud Connection, + Connect an Agent) hard-code their `page:header` copy in + `properties.title` / `properties.subtitle`. Every other metadata type is + localized at the REST boundary, but `page` was not: the `pages` namespace + existed only on `AppTranslationBundleSchema` — a schema no runtime reads — + with no resolver behind it, so those headers stayed English in every locale + while the matching nav labels translated correctly. + + - `TranslationDataSchema` (the shape the i18n service actually serves) gains a + `pages` namespace: `pages..{label,description,title,subtitle}`. + - New `translatePage` in `@objectstack/spec/system` translates a page's own + `label` / `description` and overlays `title` / `subtitle` onto every + `page:header` in the page's regions. Registered in + `translateMetadataDocument`, so it rides the existing read path. + - `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale + extraction, the locale-keyed ETag, and `Vary: Accept-Language` already + covered every metadata type — no new plumbing. + - `objectstack i18n extract` now emits page entries, including the + `page:header` copy, so the new namespace is not invisible to the tooling. + - zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus + the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these + existed only in zh-CN). + + Header copy is keyed by **page name**, not by component id: `page:header` + instances carry no stable id. `title` falls back to `pages..label`, since + a page's header title and its nav label are normally the same string. + + Authoring is unchanged and English literals stay in metadata as the fallback — + a page with no `pages` entry renders exactly as before. Consumers of + `@object-ui` need no change: pages arrive already localized from the server. + +- 605e190: feat(spec)!: prune the still-dead aspirational config from Theme / Translation / Webhook (#3494) + + Removes the authorable-but-never-consumed props confirmed dead by the 2026-06 + liveness audit (follow-up to #1878/#1893; same treatment as the #2377 and + #3464 prunes). Authoring any of these was a silent no-op. + + ## Removed + + **Theme** (`ThemeSchema`) — the theme engine (objectui `generateThemeVars`) + never emitted or consumed them: + + - Props: `spacing`, `breakpoints`, `logo`, `density`, `wcagContrast`, `rtl`, + `touchTarget`, `keyboardNavigation` + - Exports: `SpacingSchema`, `BreakpointsSchema`, `DensityModeSchema` (+ + deprecated `DensityMode` alias), `WcagContrastLevelSchema` (+ deprecated + `WcagContrastLevel` alias), and the `Spacing` / `Breakpoints` / + `DensityMode` / `WcagContrastLevel` types + + **Translation** (`TranslationConfigSchema`) — no runtime reader; there is no + ICU engine and interpolation is always simple `{variable}` substitution: + + - Props: `fileOrganization`, `messageFormat`, `lazyLoad`, `cache` + - Exports: `MessageFormatSchema`, `TranslationFileOrganizationSchema`, and the + `MessageFormat` / `TranslationFileOrganization` types + + **Webhook** (`WebhookSchema`) — the delivery path always sends its own fixed + envelope and only applies HMAC signing via `secret`; delivery retries are owned + by the messaging outbox's fixed schedule: + + - Props: `body`, `payloadFields`, `includeSession`, `authentication` + (bearer/basic/api-key were never attached; HMAC via `secret` stays), + `retryPolicy`, `tags` + - Exports: the entire inbound `WebhookReceiverSchema` + `WebhookReceiver` type + (never consumed by any runtime) + + ## Migration + + Delete these keys from your configs — they never did anything, so removing + them changes no behavior. Parsed output no longer contains the previously + defaulted keys (`includeSession: false`, `fileOrganization: 'per_locale'`, + `messageFormat: 'simple'`, `lazyLoad: false`, `cache: true`). Webhook HMAC + signing (`secret`), `headers`, and `timeoutMs` are unaffected. File layout for + translations remains a pure authoring convention — no config knob needed. + + ## Deliberately NOT removed + + - Translation `supportedLocales` — it has a live reader (pinyin-search + capability toggle in `serve.ts`). + - Job `retryPolicy` / `timeout` — being implemented (built, not pruned) in the + #3494 follow-up PR. + - The materialized webhook props (`name`, `object`, `triggers`, `url`, + `method`, `headers`, `timeoutMs`, `secret`, `isActive`, `description`, + `label`) — live via the #3489 bridge; ledger flip tracked in #3490. + + Refs #3494, #1878, #1893. + +- c6c59f1: feat(spec)!: remove the dead `AuditConfig` cluster from `@objectstack/spec/system` (#1878 recheck loose-end) + + The entire `system/audit.zod.ts` module — `AuditConfigSchema`, + `AuditStorageConfigSchema`, `AuditRetentionPolicySchema`, + `AuditEventFilterSchema`, `SuspiciousActivityRuleSchema`, + `DEFAULT_SUSPICIOUS_ACTIVITY_RULES`, and the `AuditEvent` / + `AuditEventActor` / `AuditEventTarget` / `AuditEventChange` / + `AuditEventType` / `AuditEventSeverity` shape schemas (plus all their + type exports) — is removed. Verified zero consumers repo-wide: the live + audit path (`plugin-audit`) imports none of it, defines its own + `sys_audit_log` row shape, and captures **unconditionally** via engine + hooks, so `AuditConfigSchema.enabled: false` advertised a semantic + (turning the compliance ledger off) the platform deliberately rejects. + Same ADR-0056 D8 family as the earlier `compliance.zod` / `masking.zod` / + `RLSAuditConfig` / `PolicySchema` removals: security/compliance-shaped + config must never merely look live. + + **Migration — every dead knob maps to a live surface (or is deliberately + not configurable):** + + | Removed (never enforced) | Live replacement | + | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | + | `AuditConfigSchema.enabled` | none — audit capture is **always on** (compliance ledger; `object.zod` `trackHistory` contract) | + | `eventTypes` / `excludeEventTypes` / `minimumSeverity` / `AuditEventFilterSchema` | none today — if event filtering ships it lands as an `audit` **settings** namespace (ADR-0069 pattern), not app metadata | + | which fields/objects are summarized + History tab UI | object-level + field-level **`trackHistory`** (live, enforced by plugin-audit) | + | `AuditRetentionPolicySchema` / `storage` | object **`lifecycle`** `audit` category (retain → archive → delete) + per-org settings overrides (ADR-0057) | + | `SuspiciousActivityRuleSchema` / `DEFAULT_SUSPICIOUS_ACTIVITY_RULES` | none — no detection engine exists; security monitoring is org-operations tooling, not app-package metadata | + | `AuditEvent*` shape schemas | the `sys_audit_log` object definition in `plugin-audit` is the row-shape source of truth | + + No first-party, example, or downstream-contract code imported any of + these symbols; `defineStack` never accepted an `audit` key, so no stack + config changes. Docs page `references/system/audit.mdx` is removed by + regeneration; the security-context module doc now marks audit alongside + the previously removed compliance/masking subsystems. + +- b0e78a8: feat(spec)!: remove the dead static capabilities-descriptor cluster (`ObjectQL`/`ObjectUI`/`Kernel`/`ObjectStack`/`ObjectOS CapabilitiesSchema`) (#1878 family) + + The "RUNTIME CAPABILITIES PROTOCOL" tail of `stack.zod.ts` — `ObjectQLCapabilitiesSchema`, + `ObjectUICapabilitiesSchema`, `KernelCapabilitiesSchema`, `ObjectStackCapabilitiesSchema`, + the deprecated `ObjectOSCapabilitiesSchema` alias, and all five inferred types — is + removed. Verified zero consumers repo-wide (framework, objectui apart from bare + re-exports, cloud, downstream-contract): it was never authorable (`defineStack` has + no such key), never registered, and never fed any endpoint. + + Worse than dead, it **lied**: the fixed-boolean self-portrait defaulted + `fieldLevelSecurity` / `rowLevelSecurity` / `auditLogging` / `backgroundJobs` to + `false` while every one of those is live and enforced on the platform, and + advertised `odataApi` which has never existed. An AI reading the schema would + build a systematically wrong model of the platform. + + **Migration — runtime capability discovery is dynamic, not a static schema:** + + | Removed | Live replacement | + | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `KernelCapabilitiesSchema` booleans (`restApi`/`websockets`/`auditLogging`/…) | `GET /api/v1/discovery` — dynamic `capabilities` record with **declared === enforced** discipline (#3298: a capability is advertised only when the route is actually mounted AND the engine supports it) | + | `WellKnown`-style backend feature probes | `WellKnownCapabilitiesSchema` (`@objectstack/spec/api` discovery contract: `comments`/`automation`/`cron`/`search`/…) | + | `ObjectQLCapabilitiesSchema` driver/query booleans | driver-level `DriverCapabilities` / `DatasourceCapabilities` (`@objectstack/spec/data`) — per-connection, resolved at runtime | + | `ObjectStackCapabilitiesSchema` layer roll-up + `ObjectOS*` aliases | none — no replacement import | + + The objectui `@object-ui/types` re-exports of these symbols are dropped in the + companion objectui change. `ClusterCapabilityConfigSchema` / `FeatureFlagSchema` / + `ApiEndpointSchema` (referenced by the dead cluster) are untouched — they live in + their own modules and `ApiEndpointSchema` remains consumed by the stack `apis` key. + +- f31cc8d: refactor(spec)!: finish the 2026-06 field prune — drop both orphaned value schemas, `DataQualityRulesSchema` and `ComputedFieldCacheSchema` (#3726, #3733) + + **BREAKING** (the `!` marker and this changeset are the breaking-change record; + the train ships as the v17 major — see the `v17-rc-anchor` changeset), though + nothing in-tree or out could have depended on either meaningfully. Removed from + the `@objectstack/spec` public surface: + + - `DataQualityRulesSchema` (const), `DataQualityRules` (type), `DataQualityRulesInput` (type) — #3726 + - `ComputedFieldCacheSchema` (const), `ComputedFieldCache` (type) — #3733 + + and the published `data/DataQualityRules.json` / `data/ComputedFieldCache.json` + JSON Schemas. + + **Why.** Five field keys were pruned in 2026-06 — `encryptionConfig`, + `maskingRule`, `auditTrail`, `cached` and `dataQuality` — as "dead in both + layers, aspirational governance with no runtime consumer" (see + `docs/audits/2026-06-dead-surface-disposition-plan.md`, P0/P2 field prune). + Three of the five took their value schemas with them. Two did not: `dataQuality` + and `cached` each lost their key from `FieldSchema` while + `DataQualityRulesSchema` / `ComputedFieldCacheSchema` stayed on the published API + surface and in the generated reference docs, with zero consumers anywhere in the + tree. The tombstone claimed "dead in both layers"; for these two it was true of + only one. + + That middle state is the worst of the three available (key + schema + consumer / + none of them / schema only), and it failed quietly rather than loudly. + `FieldSchema` is **not** `.strict()`, so an author who found either type in the + reference docs and wrote `dataQuality: { uniqueness: true }` or + `cached: { enabled: true, ttl: 3600 }` got no error at all — the field parsed + clean and the key was silently stripped, leaving a rule that was declared in + source, absent from the contract, and enforced by nothing. That is the ADR-0104 + failure class, the same one the `accept` / `maxSize` declarations were added to + close. + + Each had its own sharp edge. `DataQualityRules.uniqueness`, described as "Enforce + unique values across all records", reads exactly like the platform-wide scope + that `unique: 'global'` actually provides (#3696), making it the option an author + was most likely to reach for by mistake. `ComputedFieldCache` was quieter and + therefore harder to catch: an author writing `ttl: 3600` on a formula field would + believe results were cached for an hour, get no error, and never see a signal + that nothing had happened. + + **Migration.** There is no runtime behavior to migrate — neither schema was ever + reachable from `FieldSchema`, and neither had a consumer in-tree. For per-field + uniqueness use `unique` (`true` = unique within the tenant, `'global'` = unique + platform-wide; see #3696). `completeness`, `accuracy`, and computed-field caching + (`enabled` / `ttl` / `invalidateOn`) have no replacement; none was ever + implemented. + + If field-level data-quality governance or computed-field caching is built for + real later, re-add the field key and its schema **together, with a consumer** — + the enforce side of enforce-or-remove (ADR-0049). A tombstone in `field.zod.ts` + records this so neither schema is restored on its own again. + +- f343dc4: feat(spec)!: remove the orphaned `FeatureFlagSchema` module (`@objectstack/spec/kernel` feature.zod) + + Follow-through of the capabilities-descriptor prune (#3605). `kernel/feature.zod.ts` + (`FeatureStrategy`, `FeatureFlagSchema`, the `FeatureFlag` factory and its + `FeatureFlag` / `FeatureFlagInput` types) had **zero runtime consumers**, and its + only protocol home — the static `ObjectStackCapabilities.system.features` + descriptor — was itself removed as dead in #3605 (no endpoint ever served it). + The module was a compile-checkable shape with nowhere to go: not authorable + (`defineStack` has no `features` key; strict parsing strips it), not registered, + not read by any engine. + + **Migration — flags are runtime configuration, not authored metadata:** + + | Removed | Live replacement | + | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `FeatureFlagSchema` / `FeatureFlag.create()` rollout documents (strategies, percentage/group conditions) | the `feature_flags` **settings manifest** (`@objectstack/service-settings`, ADR-0007) — org-tunable `ai_enabled` / `beta_*` toggles, env-overridable via `OS_FEATURE_FLAGS_*` | + | deployment-level capability gating | `PUBLIC_AUTH_FEATURES` registry (`kernel/public-auth-features.ts`) + `requiresFeature` sugar on actions/params (unchanged, live) | + | runtime capability discovery | `GET /api/v1/discovery` (dynamic, declared === enforced) | + + Docs regenerated (`references/kernel/feature.mdx` removed); the platform skill's + Feature Flags section and the hand-written quick-reference row now point at the + settings surface; `PROTOCOL_MAP.md` row dropped. + +- 8269e32: feat(spec)!: remove the dead PortalSchema (portal metadata was never enforced) + + `PortalSchema` and its top-level `portals` collection on `StackSchema` were a + forward-looking design that was **never wired to a runtime** — no metadata-type + registration, no dispatcher route family, no auth scope, and no + LayoutDispatcher / NavigationBuilder / ThemeProvider ever consumed it. Authoring + a portal was already documented as a no-op and marked + `[EXPERIMENTAL — not enforced]`. This removes the dead schema rather than + building a portal runtime (issue #3464, disposition **A — prune**). + + **Removed exports** (`@objectstack/spec`, from `ui/portal.zod`): + `PortalSchema`, `Portal`, `definePortal`, and the `PortalInput` / + `PortalTheme` / `PortalNavItem` (+ `PortalViewNavItem`, `PortalActionNavItem`, + `PortalDashboardNavItem`, `PortalUrlNavItem`) / `PortalAnonymousEntry` / + `PortalAnonymousRoute` / `PortalRateLimit` / `PortalSeo` / `PortalAuthMode` / + `PortalLayout` schemas and inferred types. The `portals` key is removed from + `StackSchema` / `defineStack()`. + + **Migration**: none required for behavior — authoring a portal had no runtime + effect. Any `portals: [...]` entry in a `defineStack()` config was already + ignored at runtime and should be deleted (with the schema gone it is an + excess-property type error). To project a scoped UI to external users today, + compose the existing `apps` / `views` surfaces and gate admission with + `positions` + permission sets (`externalSharingModel` on the objects you + expose). + + Refs #3464, #1893, #1878. + +- 74f7339: feat(spec)!: prune the dead `aria` / `performance` props from ReportSchema (report-liveness close-out) + + Follow-up to the #3463 report cleanup. The 2026-06 ReportSchema liveness audit + flagged `aria` and `performance` as dead — declared on `ReportSchema` (and + editable in the Studio report form) but read by **no renderer**. This removes + them. Every other finding from that audit is now closed too: `chart` turned out + to be **live** (`DatasetReportRenderer` plots `chart.xAxis`/`yAxis` via + `DatasetReportChart`), and the obsolete sub-schemas / naming-drift / joined-preview + items were resolved by #3463 and earlier work. + + - Removed `ReportSchema.aria` (`AriaPropsSchema`) and `ReportSchema.performance` + (`PerformanceConfigSchema`), dropping the now-orphan imports. Both schemas + remain exported and are still used by other metadata types (views, pages, + charts) — only the report's use of them is removed. `ReportChart` keeps its + own `aria` (inherited from `ChartConfigSchema`). + - No manifest key or public export changes (`aria`/`performance` were properties, + not schemas); `report.mdx` regenerated. + + **Migration**: nothing an author writes changes — no first-party or example + report set `aria`/`performance`. Reports carry no ARIA/performance overrides; + use the dataset/view surface for those concerns. Ships as `minor` per the + launch-window breaking-as-minor policy. + +- a6c35a2: feat(spec)!: prune the dead `ReportColumnSchema`/`ReportGroupingSchema` exports + the unread report chart `groupBy` (#3463, #1878/#1890) + + Deep-cleanup close-out of the report-chart disposition (follow-up to #3441). + After the ADR-0021 single-form cutover a dataset-bound report expresses its + columns/grouping as dataset **measure/dimension name arrays** — `values`, + `rows` and `columns` are `z.array(z.string())`, not object literals — so + `ReportColumnSchema` / `ReportGroupingSchema` were referenced by **no schema + body**. They survived only as public type exports and were marked + `@deprecated` in #3441; this removes them. + + - Deleted `ReportColumnSchema` / `ReportGroupingSchema` and their type + exports `ReportColumn` / `ReportGrouping` / `ReportColumnInput` / + `ReportGroupingInput` from `@objectstack/spec/ui`. The manifest ratchet + keys `ui/ReportColumn` / `ui/ReportGrouping` are dropped in the same PR. + - Deleted `ReportChart.groupBy` — the `[EXPERIMENTAL — not enforced]` + series-split field flagged in #3441. The dataset-bound `DatasetReportRenderer` + plots a single `xAxis`×`yAxis` series and never read it; only the retired + legacy `ReportViewer` fallback ever consumed a top-level `groupBy`. + `ReportChartSchema` is non-strict, so any residual `chart.groupBy` in stored + metadata is silently stripped on parse — no tombstone needed. + - Regenerated `content/docs/references/ui/report.mdx` and the spec API-surface + snapshot. + + **Migration**: nothing an author writes changes. + + - No first-party or example report authored `ReportColumn` / `ReportGrouping` + objects or `chart.groupBy` — a dataset-bound report already expresses + columns as `values` (measure names) and grouping as `rows` / `columns` + (dimension names). + - TypeScript consumers importing `ReportColumn` / `ReportGrouping` / + `ReportColumnInput` / `ReportGroupingInput` (or the `*Schema` values) from + `@objectstack/spec/ui` have no replacement type — model report columns as + the dataset's measure names and grouping as its dimension names. objectui's + `SpecReportColumn*` / `SpecReportGrouping*` re-exports are removed in the + companion objectui change. + +- c2f1002: feat(spec)!: remove `SkillSchema.permissions` — it never gated anything (#3686) + + Owner decision on the enforce-or-prune call filed in #3686: **prune**. + + `skill.permissions` was declared, surfaced in the Studio authoring form under a + section labelled _"Access — Required permissions to use this skill"_, and echoed + by the objectui preview — but **no runtime ever read it**. The cloud + `SkillRegistry` selects skills by `active` / `triggerConditions` / `tools` only. + A security-shaped field that enforces nothing is worse than no field: it invites + an author (or an AI) to believe a skill is gated when it is not. Same disposition + as agent `visibility` (#1901) and the `PolicySchema` tree (#2387). + + Removed: the schema property, the form's whole `Access` section (it existed only + for this field), its generated i18n keys, the liveness-ledger entry, and the + `permissions` line from the objectstack-ai skill doc's `os:check` example. The + objectui preview's "Required Permissions" panel is removed in the companion + objectui change. + + **Migration** — gate access where it is actually enforced: + + - **Agent level** — `access` / `permissions` on `defineAgent` ARE enforced at the + chat route (403 for a caller missing any of them, #1884). Bind the restricted + skill only to a restricted agent. + - **Action level** — gate the underlying actions the skill's tools invoke via + permission sets (ADR-0066). + + `SkillSchema` is non-strict, so an existing `permissions:` key is silently + stripped on parse rather than rejected — no boot break, but it stops appearing + anywhere. + +- f163028: Reference-integrity validation for object and action names (issue #3583) + + A HotCRM audit found ~20 shipped instances of one bug class — metadata naming + something that does not exist — all passing `objectstack validate` / `lint` + cleanly and failing silently at runtime. This closes the object-name and + action-name half of that class. + + **New — `@objectstack/spec`:** `PLATFORM_PROVIDED_OBJECT_NAMES`, a curated + registry of every object name contributed by a platform package, official + plugin, or the cloud runtime, plus `isPlatformProvidedObjectName()` and + `hasPlatformObjectPrefix()`. This replaces the `startsWith('sys_')` prefix guess + that could not tell `sys_user` (real) from `sys_approval_process` (fictional — + removed by ADR-0019, registered by nothing), which is why every fictional + platform-prefixed reference shipped. A conformance test scans each package's + `*.object.ts` declarations and fails if the registry drifts. + + **New lint rules** (wired into both `os validate` and `os lint`): + + - `validate-object-references` — action-param `reference` / `objectOverride`, + dashboard `globalFilters[].optionsFrom.object`, and navigation + `requiresObject` gates. Severity follows resolvability: an unresolved + _unprefixed_ name is a typo (**error** — `object: 'user'` where the platform + object is `sys_user`); an unresolved _platform-prefixed_ name is **advisory**, + since a third-party package may still provide it. + - `validate-action-name-refs` — the surfaces that bind an action BY NAME: + list-view `bulkActions` / `rowActions`, page `record:quick_actions` + `actionNames`, and nav action items. A name matching no defined action is an + **error** (the button renders and does nothing), matching the existing + dashboard-action-target rule. + + **Fixes:** + + - `defineStack` cross-reference validation now walks `app.areas[].navigation` — + an areas-based app previously got no navigation checking at all — and recurses + into `children` on `object` nav items, not only `group` ones. + - `os lint` i18n coverage now reads field `options` in the canonical + `{value,label}[]` array shape; it only handled the record map, so option-label + coverage silently never fired for canonically-shaped select fields. + - Hook `condition` expressions are now field-checked when `object` is an ARRAY + of targets (previously only a single string target was checked, so a + multi-target hook filtering on a nonexistent field passed clean). Per-target + diagnostics are de-duplicated. + - A dashboard widget binding no `dataset` at all is now reported instead of + silently bypassing every binding and chart check on the raw-config + (`lint`/`doctor`) paths. `dataset` is schema-required, so this matches what + the parsed paths already enforce. + +- 7ffc3d3: feat(client,spec)!: delete the 21 dead SDK methods and the four ghost route + tables that underwrote them (#3612, #3587 finding) + + Five client surface families built URLs that exist on NO server surface — + not the dispatcher, not `@objectstack/rest`, not the autonomous service + mounts — so every call was a guaranteed 404: + + - `permissions` (check, getObjectPermissions, getEffectivePermissions) + - `realtime` (connect, disconnect, subscribe, unsubscribe, setPresence, + getPresence) — `service-realtime` registers zero HTTP routes and the + dispatcher deliberately never advertises `/realtime` + - `workflow` (getConfig, getState, transition) + - `views` CRUD (list, get, create, update, delete) — no `/ui/views` route + anywhere + - `notifications` device/preference helpers (registerDevice, + unregisterDevice, getPreferences, updatePreferences) — the ADR-0012 + server side was never built + + Each family was underwritten only by an unconsumed spec `DEFAULT_*_ROUTES` + table — the same disease `DEFAULT_DISPATCHER_ROUTES` had (#3586) — so + `DEFAULT_PERMISSION_ROUTES`, `DEFAULT_VIEW_ROUTES`, `DEFAULT_WORKFLOW_ROUTES`, + and `DEFAULT_REALTIME_ROUTES` are deleted with them; + `getDefaultRouteRegistrations()` now returns 9 registrations. + `ApiRouteType` loses its client-only `'views' | 'permissions'` extras. + + Kept: `client.events` (explicitly local in-memory buffer, no HTTP), + `notifications.list/markRead/markAllRead` (dispatcher-served), + `approvals.*` (ADR-0019 — the real approval decision API), and + `meta.getLegalNextStates` (the real FSM read). + + Breaking for anyone calling the removed methods — a repo-wide and + objectui-wide sweep found one consumer (`useClientNotifications`'s dead + device/preference delegates, trimmed in the objectui companion change); + shipped as minor per the launch-window convention (cf. #3562/#3581/#3595). + Re-adding any of these surfaces requires the server route to exist and a + route-ledger row proving it (#3569/#3609 guards). + +- 88346ba: feat(spec)!: remove the dead `object.enable.trash` / `enable.mru` capability flags (#2377, ADR-0049 enforce-or-remove — close-out) + + Both flags parsed and defaulted to `true` but had **no runtime consumer**: + every delete has always been a hard delete (no recycle bin), and no MRU + tracking was ever implemented. A default-true flag promising recoverability + is the worst kind of false affordance — first-party objects were authoring + `trash: false // Never soft-delete audit logs` in the belief that a + soft-delete existed to opt out of. + + - `ObjectCapabilities` is now **`.strict()`** (pattern of the tenancy block, + #2763): an unknown `enable` key — the retired `trash`/`mru` or a typo like + `feedEnabled` — fails parse with upgrade guidance instead of stripping + silently (#1535). The retired-key tombstones live in + `CAPABILITIES_RETIRED_KEY_GUIDANCE`. + - ~45 first-party object definitions (platform-objects, plugin-security, + plugin-audit, plugin-approvals, plugin-sharing, metadata-core, + service-realtime, examples) dropped their inert `trash:`/`mru:` lines. + - Liveness ledger: both entries deleted (removal precedent: `tags`/ + `recordName`); object row in the README count table now shows **0 dead**. + - Docs + skills no longer advertise a recycle bin / MRU tracking; the API + skill's "DELETE is soft-delete when `trash: true`" claim is corrected to + the real contract (hard delete; use per-field `trackHistory` or a + `lifecycle` policy for recoverability). + + **Migration**: delete any `enable.trash` / `enable.mru` keys from object + metadata — they never changed behavior. `ObjectSchema.create()` / + `ObjectCapabilities.parse()` now reject them with this prescription. A real + recycle bin or MRU feature, if built, returns as a live enforced flag + (#1893 prune-or-build). + +- 4631592: feat(spec)!: remove the never-implemented GraphQL surface from the product plan (#2462 follow-on) + + GraphQL was schema-only from day one: the spec shipped 20+ config schemas + (`GraphQLTypeConfig`, federation, persisted queries, …), the dispatcher's + `handleGraphQL` answered 501 unconditionally (`kernel.graphql` was never + assigned in the monorepo), and THREE separate mounts advertised the dead + endpoint. Per the product decision, the surface is deleted rather than + maintained: + + - **spec**: `api/graphql.zod.ts` + `contracts/graphql-service.ts` deleted; + `graphql` removed from `CoreServiceName`, `ApiProtocolType`, the + query-adapter dialects, `graphql-playground` from testing-UI types; the + `graphqlApi`/network capability booleans, discovery/router route fields + dropped. Breaking for consumers referencing those exports/enum members (shipped as minor per the launch-window convention, cf. #3486/#2377). + - **runtime**: `handleGraphQL`, the if-chain branch, the dispatcher-plugin + and hono-adapter mounts, discovery advertisement, and the now-dead + `resolveRequestExecutionContext` helper removed. + - **plugin-dev**: the graphql stub family removed. + - **qa**: authz-conformance matrix rows, ratchet high-risk id, discover + patterns and identity pins for the GraphQL surface retired; expression + ledger covers updated. + - **NOT removed**: the `'graphql'` protocol option on external datasource + lookups (third-party systems may speak GraphQL) and cloud's reserved + slug — those are not our API surface. + + `/graphql` now 404s (was an unconditional 501); the anonymous-deny posture + matrix shrinks by the two GraphQL rows. + +- 5ac93d4: feat(rest): surface silently-dropped write fields on PATCH/POST /data (#3431) + + #3413 (closes #3407) built the engine-level strip-observability channel + (`WriteObservabilityOptions.onFieldsDropped`) and wired the flow side + (`update_record` / `create_record` emit a step warning + `droppedFields`). The + **REST write path was never wired**, so an external API caller writing N fields + still got a bare `200 + record` when `readonly` (#2948) / `readonlyWhen` (#3042) + stripping meant `< N` actually landed — the same silent-success class #3407 + fixed flow-side, just on HTTP. The only way to notice was a per-field diff of + the returned row (which need not echo every field). This wires the channel + through the protocol → REST, on both write verbs. + + **Passthrough (metadata-protocol).** `updateData` now registers an + `onFieldsDropped` collector on `engine.update` and returns the events on the + response as `droppedFields`. `createData` surfaces the #3043 static-`readonly` + INGRESS strip too — that strip runs at the protocol ingress + (`stripReadonlyForInsert`), _before_ the engine, so it is recovered by diffing + the supplied payload against the stripped one (the engine's `onFieldsDropped` is + also wired for a future insert-side engine strip). A faulty listener never + breaks the write — the engine catches and logs. + + **Contract (spec).** `UpdateDataResponseSchema` / `CreateDataResponseSchema` + gain an **optional** `droppedFields: DroppedFieldsEvent[]` — present only when + ≥1 field was dropped. Optional + omit-when-empty keeps the response shape + backward-compatible for clients that only read `record`. + + **REST surface.** PATCH `/data/:object/:id` and POST `/data/:object` echo the + drops as an `X-ObjectStack-Dropped-Fields` response header + (`field;reason=` tokens, comma-joined — e.g. + `approval_status;reason=readonly`) and keep the structured `droppedFields` on + the body. **Status/success semantics are unchanged** (200 update / 201 create) — + a strip is legitimate semantics, not a failure (same principle as #3413). The + FLS write gate is untouched (it already fails closed with 403). + + Out of scope (issue #3431 D2 open questions, deferred): bulk + (`updateManyData` / `createManyData` / `batchData`) and GraphQL mutation wiring, + typed `@objectstack/client` warnings, and adding the header to the Hono CORS + `exposeHeaders` allow-list for cross-origin browser reads (the body + `droppedFields` is the cross-origin-safe channel meanwhile). + +- 93f267f: fix(automation): one chokepoint for the resume signal — `output` reopened the hole `inputs` had just closed (#3879) + + #3853 guarded `signal.variables` at the route. That closed one of **two** + equivalent paths into the same variable map and left the other open: + `signal.output` keys are merged under `${run.nodeId}.${key}`, and for a run + parked on a `map` node `run.nodeId` **is** the map node — so + + ```jsonc + { + "output": { "$mapItemDone": true, "$mapItemOutput": { "result": "FORGED" } } + } + ``` + + writes exactly the `.$mapItemDone` the `inputs` guard had refused, + making the map record a result for an item nobody decided. Demonstrated with a + repro, then fixed. + + Scope: the #3853 map gate still held, so a batch whose pending item sits on an + `approval` was refused before any of this — the **approval bypass stayed + closed**. The residual was forging the recorded result of an item on an + _ungated_ pause. + + Two escapes with one shape is a design signal, not two bugs, so the fix is + structural rather than a third patch: + + - **`applyResumeSignal` is the one place a resume signal reaches the variable + map.** Both fields are collected into a single write list (already in final, + prefixed form), checked, then applied — a new signal field is covered by + construction rather than by remembering. + - **All-or-nothing**, and checked _before_ the suspension is consumed: a + rejected signal applies nothing (not even legitimate keys sent alongside) and + the run stays parked, so the real continuation still lands. + - **The engine owns the rule; the transport maps the verdict.** `resume` returns + `{ success: false, code: 'invalid_signal' }`; the route answers **400**. The + SDK and any future adapter inherit it — implemented in one transport it + protected exactly one transport, and one field of it. + - Engine-built signals (the subflow output mapping, the map item handoff) are + exempt via a module-private symbol. Deliberately _not_ + `RESUME_AUTHORITY_SERVICE`: that marker means "the owning service authorized + this decision", and a service still has no business writing engine internals. + + `AutomationResult.code` gains `'invalid_signal'` alongside `'forbidden'` — a + `switch` over it needs a new arm; a plain read does not. + + Nothing changes for authoring: ordinary variables pass, `$` mid-name (`price$`) + and dotted names (`collect.note`) included. Only names the engine reserves — + `$…` or a `.$` segment — are refused. + +- 0024abf: feat(spec)!: delete `DEFAULT_DISPATCHER_ROUTES` — the dead route table that + underwrote a false compliance verdict (#3586, #3563 follow-up) + + The const was consumed by nothing in the runtime — only its own tests and + `api-surface.json`. It listed dispatcher branches that never existed + (`/workflow`, `/realtime`) while omitting eight real prefixes (`/keys`, + `/mcp`, `/mcp/skill`, `/actions`, `/security`, `/share-links`, `/ready`, + `/openapi.json`), and `CLIENT_SPEC_COMPLIANCE.md` anchored a "FULLY + COMPLIANT" verdict on it while 27 real routes had no SDK expression. + + The audited, guard-enforced source of truth for the dispatcher's route + surface is `packages/runtime/src/route-ledger.ts` (#3569): the conformance + suite fails when the registry and the ledger drift, which the dead table + never could. + + Also swept the last GraphQL fixture debris that #3562's surface removal + left behind: registry test fixtures renamed to honest OData naming, the + tautological `config.graphql` assertions dropped, and the stale + `"type": "graphql"` JSDoc example in `registry.zod.ts` corrected. + + Breaking for anyone importing `DEFAULT_DISPATCHER_ROUTES` (a repo-wide and + objectui-wide grep shows zero consumers); shipped as minor per the + launch-window convention, cf. #3562/#3581. + +- 7687f7b: fix(automation): a screen field's `visibleWhen` reaches the client (#3528) + + `visibleWhen` has been on the `screen` node's designer form since #3304 — + declared as an expression (`xExpression`), documented as bare CEL, offered to + authors in Studio. The executor never put it on the wire. `ScreenFieldSpec` + carried `name` / `label` / `type` / `required` / `options` / `defaultValue` / + `placeholder` and nothing else, so no client could honour a predicate it never + received. Authors wrote conditional visibility; every field rendered + unconditionally; nothing errored. + + That is worse than a cosmetic miss, because `required` **is** honoured. A field + that is optional-by-design but required _when shown_ becomes permanently + required once its predicate is dropped — and a runner that validates the full + field list then blocks Submit on input the user was never asked for. No resume + request is issued and the run sits paused forever. HotCRM's lead-conversion + screen is exactly that shape: + + ```ts + { name: 'createOpportunity', type: 'boolean', required: true }, + { name: 'opportunityName', type: 'text', required: true, + visibleWhen: 'createOpportunity == true' }, + ``` + + Leave the checkbox unticked and `opportunityName` — which should not be on + screen at all — blocks the whole conversion. + + - `ScreenFieldSpec.visibleWhen` is now part of the contract, documented as + client-evaluated bare CEL over the screen's own field names, with the + `required`-must-follow-visibility rule stated where implementors will read it. + - The `screen` executor forwards it **raw**, deliberately uninterpolated: the + predicate is re-evaluated per keystroke against values only the client has, so + resolving it server-side against flow variables would freeze the field. + - Covered by tests — the screen wire payload had none for this key. + + Clients must evaluate the predicate and skip hidden fields when enforcing + `required`. Honouring one without the other reproduces the dead-end above. + +- 1659072: feat(spec): publish `ISecurityService` — the `security` service surface becomes an enforced contract + + The `security` service registers seven cross-package methods (`getReadFilter`, + `getReadableFields`, `resolvePermissionSetNames`, `explain`, and the three + audience-binding suggestion calls) but had no contract in + `@objectstack/spec/contracts`. Consumers duck-typed it, and each one invented its + own fallback for a missing method or an "empty" answer — with more consumers + arriving, that is a drift surface. + + `ISecurityService` now documents the surface, and both ends are typed against it + so it is **enforced rather than declared**: `plugin-security` assigns its + registration to `ISecurityService` (a renamed, dropped, or re-typed method fails + that build), and the REST layer resolves the service as a `Partial` + (so call sites must keep feature-detecting instead of assuming the full surface). + + The contract makes explicit the one thing consumers cannot guess — that the + methods do **not** share a failure convention: + + - `getReadFilter` fails **CLOSED**: a resolution failure yields a deny filter + matching zero rows, never `undefined`. `undefined` means "no row restriction", + and nothing else. + - `getReadableFields` fails **SOFT**: `undefined` means "no answer, use your own + projection", while `[]` is authoritative and means "no field is readable" — + opposite instructions that a consumer must not conflate. + + Typing the producer immediately caught one real discrepancy, fixed here: + `getReadFilter` declared `Promise | null | undefined>` + while every return path yields a filter or `undefined` (`filter ?? undefined` + normalizes the null away). The dead `| null` is removed, so "no restriction" has + exactly one representation. Type-level only — no runtime behaviour changes. + +- f00d8d4: fix(sharing): remove the `full` access level — it promised delete/transfer/share and granted `edit` (#3865) + + `sys_sharing_rule.access_level` / `sys_record_share.access_level` offered three + levels, the third documented as **Full Access (Transfer, Share, Delete)**. No + code path granted transfer, re-share, or delete because of it: both enforcement + sites matched `access_level in ('edit','full')`, so `full` was byte-equivalent + to `edit`. An admin picking "Full Access" in Setup was told they had granted + delete rights and had not — declared-but-unenforced metadata (ADR-0078, + ADR-0049), the same defect that retired the `queue` recipient before it. + + Measured on showcase, a `full` recipient got `read: allowed`, `update: allowed`, + `delete: DENIED` — and the denial came from `decidedBy=object_crud`, i.e. the + object-level CRUD gate rejected the delete _before_ sharing was consulted at + all. That is not an oversight to patch around; it is the model working. Record + sharing widens **which rows** a principal reaches, never **which verbs** they + may use — the same split Salesforce enforces (its sharing rules stop at + Read-Only / Read-Write; Full Access is owner / hierarchy / Modify All only, + never grantable by a rule) and Dataverse enforces by AND-ing every shared access + right against the security role's own privilege. Delete and transfer belong to + ownership, the ADR-0057 DEPTH scopes, and admin scope. + + **What changed** + + - `SharingLevel` (spec/security) and `ShareAccessLevel` (spec/contracts) are now + `read | edit`. The `Field.select` on both objects offers the same two, so the + Setup dropdown no longer shows the misleading option. + - `SharingService.grant()` and `SharingRuleService.defineRule()` gained the + access-level validation they never had: `full` normalises to `edit`, and an + unrecognised level is a `VALIDATION_FAILED` (HTTP 400) instead of being + persisted verbatim as a grant no gate would ever match. + - Enforcement stays deliberately wider than authoring — the read/write gates + still match `edit`/`full` — so a row written before this release keeps + working. Narrowing them would silently _revoke_ access. + - A boot backfill normalises stored `full` rows on both tables, and the + `sharing-rule-access-level-full-to-edit` conversion rewrites declarative + stacks at load, so nothing needs consumer action. + + **Migration.** None. `full` and `edit` were already behaviourally identical, so + rewriting one to the other cannot change an access decision — unlike the OWD + `sharingModel: 'full'` alias retired in ADR-0090 D4, which changed posture and + had to be delegated to the author. A stack that still authors `accessLevel: +'full'` converts at load with a deprecation notice; stored rows normalise at + next boot. Code that pinned the `ShareAccessLevel` type to `'full'` no longer + compiles — use `'edit'`. + + Reviving a real per-record delete grant is a separate design (a capability mask + AND-ed with object CRUD, plus the share-administration model that would have to + authorise re-sharing), not a fourth enum member. + +- 503be86: feat(security)!: reconcile the SharingRule authoring surface with the enforced runtime — rename `group` → `team`, add `business_unit`, prune `guest` + owner-type rules (#1878) + + The authoring `ShareRecipientType` enum had drifted behind the ADR-0090 D3 + rename and the enforced runtime: the runtime expands `team` (via + `sys_team`/`sys_team_member`) and `business_unit`, but the authoring enum + still offered the pre-rename `group` (silently skipped at seed time) and + omitted the two live recipients. After this change **every authorable + recipient and rule type is enforced** — nothing on the SharingRule surface + validates and then silently does nothing (ADR-0078). + + - **`sharedWith.type: 'group'` → `'team'`** (wire-rename): the enum member is + renamed to match the runtime vocabulary and now maps through the seed + bootstrap to the live `TeamGraphService` expansion. Flat `sys_team` + membership; enforced. + - **`business_unit` added** to the authoring enum — exactly one business + unit's members (no subtree; use `unit_and_subordinates` for the subtree). + The runtime + bootstrap already enforced it; only the enum omitted it. + - **`guest` removed** — it had no runtime recipient mapping. Anonymous access + is served by the public-form grant and share links, not sharing rules. + - **Owner-type rules removed** (`type: 'owner'`, `ownedBy`, + `OwnerSharingRuleSchema` + its type export): they depend on live + team/position membership, which the static materialiser cannot track, so + they validated but never materialised a share. They return as an enforced + form if membership-reactive re-materialisation is designed. + `SharingRuleSchema` is now the criteria form; the `queue` recipient stays + runtime-reserved (no `sys_queue` yet) and deliberately non-authorable. + + **Migration** (stale definitions now fail parse with the valid options listed): + + - `sharedWith: { type: 'group', … }` → `sharedWith: { type: 'team', … }`. + - `sharedWith: { type: 'guest', … }` → delete the rule; expose the records + via a public form or share link instead. + - `type: 'owner'` rules → rewrite as a `type: 'criteria'` rule scoping the + rows by field values (see the migrated examples: + `share_open_tasks_with_manager` in app-showcase, + `share_active_leads_with_manager` in app-crm), or use a scope-depth grant. + +- 4d00b13: feat(spec)!: remove `tool.requiresConfirmation` — a safety flag nothing enforced (#3715, ADR-0033 §2) + + `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read + it. Not the LLM tool set (a tool reaches the model as name/description/parameters + only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the + MCP bridge — which derives `destructiveHint` from a hardcoded name list. Setting + it on a destructive tool produced **no pause**. + + For an ordinary dead property that is untidy. For a **safety** property it is + false compliance, which is the case ADR-0049 exists for: an author gates a + destructive tool, sees the flag accepted, and ships believing a human is in the + loop. It is made worse by the near-miss — `action.ai.requiresConfirmation` has + the same name and **does** work, so the mistake reads as correct in review. + ADR-0033 §2 already resolved to delete this one. + + ## Migration + + - **FROM:** `requiresConfirmation: true` on a tool definition + - **TO:** put the operation behind an action and set `ai.requiresConfirmation: +true` there — that is the flag the HITL approval queue reads + (`packages/runtime/src/action-execution.ts`) and the only path that actually + stops execution. + - For AI _metadata_ mutations there is nothing to migrate: the ADR-0033 + draft/publish workspace is the gate — nothing is live until a human publishes. + + **`ToolSchema` is now `.strict()`.** This is load-bearing, not tidying. Removing a + key from a non-strict schema swaps one silent no-op for another: zod strips the + key wordlessly, the author keeps writing it, and the safety flag goes on meaning + nothing — the "silent strip" ADR-0032 / #1535 closed for objects. The retired key + now **rejects**, and the error carries the FROM → TO above, because a parse error + is the one channel every consumer bumping `@objectstack/spec` is guaranteed to + hit. + + Strictness applies to _all_ unknown keys on a tool definition, so a typo + (`buildIn`, `catagory`) is now a located parse error instead of a silently + dropped field. + + Also removed: the Studio form row, its four generated locale bundles (the + `en`/`zh-CN`/`ja-JP`/`es-ES` strings still promised _"Ask user to approve before + executing (for destructive actions)"_ — a translated false promise), the + liveness-ledger entry, and the generated reference-doc row. + + objectui's `ToolPreview.tsx` reads the field via `!!d.requiresConfirmation`, so it + degrades to "not shown" with no error; removing that badge is a follow-up in that + repo. + +- 57bab76: Typed `decisionOutputs` declarations (#3447 follow-up). A `decisionOutputs` entry may now be `{ key, label?, type: 'text' | 'user' | 'department' | 'position' | 'team', multiple? }` alongside the bare-string form — a typed entry tells the decision UI to render the matching record picker (id values; `multiple` collects an id array) instead of free text, turning "paste user ids" into "pick people". The type shapes only the input widget: the runtime whitelist works by `key` either way, via the new `normalizeDecisionOutputs` helper exported from `@objectstack/spec/automation` — the single reader of the union shape shared by the service, the request read, and `os lint`. The request read now carries `decision_output_defs` (normalized declarations) alongside the version-skew-safe `decision_outputs` key list. +- b90086a: fix(driver-sql)!: `unique` materializes per tenant, ending its contradiction with the per-tenant autonumber sequence (#3696) + + `unique: true` became a **single-column global index that ignored `tenancy` + entirely**, while the autonumber sequence table is keyed by + `(object, tenant_id, field, scope)` and hands every tenant its own counter + starting at 1. Two subsystems of the same platform contradicted each other: + tenant B's `PROD-00001` was rejected by an index it could not see — **no user + did anything wrong**, the platform's left hand refused what its right hand + issued. + + The rejection also doubled as a **cross-tenant existence oracle**: a UNIQUE + violation told tenant B that some _other_ tenant held the value, enumerable by + probing emails / codes / names. + + **The contract now:** + + | Declaration | Materializes as | + | -------------------------------- | --------------------------------------------------------------- | + | `unique: true` + tenant column | composite `(tenantField, field)` — unique **within** the tenant | + | `unique: true`, no tenant column | single-column — single-tenant DDL is byte-identical to before | + | `unique: 'global'` | single-column, always platform-wide | + + The tenant column comes first in the composite, so the index also serves the + `WHERE tenant = ?` prefix scans every tenant-scoped read issues. + + **Declared `indexes[]` are deliberately unchanged.** They are materialized over + exactly the columns listed — no tenant column is injected. The author already + spells them out, per-tenant ones have always been written explicitly + (`fields: ['organization_id', 'code']`), and many are legitimately platform-wide + (a DNS hostname, a reserved slug, an external provider id). `'global'` is + accepted there as a synonym of `true` so one vocabulary covers both spellings. + + **Migration is automatic and cannot fail.** Legacy indexes + (`

__unique` from knex, `uniq_
_` from the drift-rebuild + path) are retired inline at schema-sync time. The old global constraint is + strictly stronger than the new per-tenant one, so existing rows satisfy the + replacement by construction — no dedup, no cleanup, no data touched. It + converges at sync rather than waiting for a deliberate `os migrate` run because + a deployment that never ran migrate would otherwise stay broken. + + **Upgrading — audit your `unique: true` fields.** On a tenant-scoped object the + constraint is now per tenant. Anything that must stay platform-wide has to say + so: + + ```ts + hostname: Field.text({ unique: "global" }); // no two tenants may claim it + ``` + + Note the reach: `applySystemFields` injects `organization_id` into every + registered object unless it opts out, and the driver falls back to that column + when no `tenancy.tenantField` is declared — so most objects are tenant-scoped. + Typical candidates for `'global'`: DNS hostnames, reserved slugs, external + provider ids (Stripe customer/subscription), device identities. + + Postgres materializes `col.unique()` as a table CONSTRAINT rather than a bare + index, so the retirement tries `DROP CONSTRAINT` before `DROP INDEX` — + `DROP INDEX` alone would have made the migration a no-op on exactly the + deployments that matter most. + + `@objectstack/driver-mongodb` accepts the new declaration but keeps single-field + indexes: it implements no row-level tenancy at all (no tenant predicate on read, + no tenant stamp on write), so a `(tenant, field)` index would advertise an + isolation it does not deliver. Tracked separately. + +- b95577a: feat(automation): surface silently-stripped write fields as step warnings (#3407) + + `update_record` used to report an unconditional `success` even when the data + layer legally stripped the requested write fields — static `readonly` (#2948) + or a TRUE `readonlyWhen` predicate (#3042). The only trace was a server-side + logger warn, invisible in the flow run trace: an author saw a clean 3ms + `success` while the DB truth never changed (how #3356's approval stage + write-backs failed unnoticed). + + - **spec**: new `DroppedFieldsEventSchema` / `DroppedFieldsEvent` + (`{ object, fields, reason: 'readonly' | 'readonly_when' }`) in + `data/data-engine.zod.ts`, and a `WriteObservabilityOptions` + (`onFieldsDropped` listener) mixin on `IDataEngine.insert/update` option + params in `contracts/data-engine.ts`. The listener is a TS-contract-level, + in-process-only channel — deliberately NOT part of the serializable Zod + options schemas or the RPC boundary. + - **objectql**: `engine.update()` reports each strip pass's dropped keys + + reason through `options.onFieldsDropped` (all four strip sites: single-id + + bulk × readonly + readonlyWhen). A throwing listener never breaks the write. + System-context writes skip the readonly strip and therefore report nothing, + as before. `insert()` accepts the option for symmetry but strips nothing + today (INSERT is readonly-exempt; FLS write denial throws). + - **service-automation**: `NodeExecutionResult` and `StepLogEntry` gain + advisory `warnings?: string[]`; `update_record` / `create_record` attach one + warning per strip event naming the dropped fields, plus a structured + `droppedFields` output (`{.droppedFields}`) for downstream nodes. + `success` semantics are unchanged — stripping stays legal, it just is no + longer silent. + +- d8c4957: feat: user-level export permission axis (#3544, #3391 follow-up) + + `export` is a user-gated operation, not just "anyone who can list". A permission + set can now deny export on an object while keeping read — matching Salesforce + "Export Reports" / Dynamics "Export to Excel" / NetSuite "Export Lists" / SAP + S_GUI 61. + + - **spec** `ObjectPermissionSchema` gains an optional `allowExport` bit. It is + deliberately OPTIONAL with **no default** so it is a backward-compatible + opt-out: unset → inherits read (today's "can-list ⇒ can-export"), `false` → + export denied while read is kept, `true` → granted. + - **plugin-hono-server** `annotateEffectiveApiOperations` derives + `userExportAllowed = allowExport !== false` from the resolved per-object + permission and threads it into `resolveEffectiveApiMethods` — so `export` + derives from `list ∧ userExportAllowed`. When the axis removes `export` from + an otherwise-open object, the object is now annotated (the effective set minus + `export`) so the client hides the Export button; an unrestricted object with + export still allowed stays unannotated (client default-allow). + + Wires the `userExportAllowed` slot reserved in #3391 P1 — zero contract change + to the derivation table or the frontend (it already consumes the effective + `apiOperations`). Backward-compatible: existing permission sets (no + `allowExport`) keep today's behavior everywhere. + +### Patch Changes + +- d99aeb3: feat(spec): let an inline `lookup` action param declare its reference target (#3405) + + `ActionParamSchema` had no way to name the object an inline record-picker param + should search. Authors reasonably wrote the same key the field schema uses — + `{ name: 'inspector', type: 'lookup', reference: 'sys_user' }` — and the schema + stripped it as an unknown key, without an error. Downstream, the param dialog + saw a picker with no target and degraded it to a "paste the record id (UUID)" + text input. The authored intent was dropped silently and the user was handed a + control that a human cannot reasonably operate. + + - Added `reference` to `ActionParamSchema`, spelled to match + `FieldSchema.reference` so one spelling works in both places. It sits with the + existing inline widget config (`multiple` / `accept` / `maxSize`), which had + covered the file/image params but not the picker ones. + - A `lookup` / `master_detail` param declared **inline** with no `reference` is + now a parse-time error pointing at the missing key, instead of degrading at + render time. Field-backed params are unaffected: they inherit the target from + the referenced field's metadata, which is not visible at parse time. + +- f63cd09: fix(spec): `action.undoable` is `live`, not `experimental` — stop warning on a property that works (#3714) + + The liveness ledger marked `action.undoable` `experimental` on a #1992-era note: + _"no runtime reader yet — neither service-automation nor objectui consume the + action's `undoable` flag (objectui has an UndoManager but does not key off this + field)."_ That was true when written. objectui has since wired **two** readers, + both gating real behaviour: + + | Reader | What the flag gates | + | ------------------------------------------- | -------------------------------------------------------------------------- | + | app-shell `useConsoleActionRuntime.tsx:409` | builds the undo operation the success toast's Undo button invokes (`:147`) | + | app-shell `RecordDetailView.tsx:545` | restores the record's prior field values (`:404`) | + + `components` `action/action-button.tsx:113` forwards the flag for exactly this + reason, per its own comment: _"without this the flag is dropped and the handler + never builds the undo operation."_ + + **Why it mattered.** The CLI liveness lint warns on `experimental` as well as + `dead`, so authoring a _working_ property produced a + `liveness-experimental-property` warning — "declared but NOT enforced at + runtime". An author (or an AI) reading the ledger or that warning concludes + `undoable` is aspirational and skips it, losing a shipped feature. Authoring + `undoable: true` is now silent, and the protocol reference no longer claims + setting it "currently has no effect". + + Nothing to migrate: the schema, the parsed shape, and the runtime are unchanged + — only the classification of what they already do. + + This is the _understating_ failure direction, the mirror of the preview-renderer + over-claims corrected in #3685/#3711/#3686. Both directions have the same root + cause, now written into `packages/spec/liveness/README.md`: **a ledger entry is a + claim with a timestamp, and code moves under it in both directions** — entries + are worth re-verifying rather than trusting indefinitely. + +- 37b1346: feat(storage): surface the sys_file id on upload-complete — ADR-0104 D3 wave 2 (PR-1) + + `POST /api/v1/storage/upload/complete` now returns the opaque `sys_file` id + (`data.fileId`), and `client.storage.upload()` surfaces it on the returned + `FileMetadata`. Previously the commit response omitted the id — the caller + could not learn which id to persist after committing an upload, so a file + field could never store a reference. + + Additive and non-breaking (new optional `fileId` on `FileMetadataSchema`; the + client falls back to the presigned id when talking to an older server). This is + the enabling foundation for file-as-reference; the storage model itself is + unchanged in this PR. + +- 201b31f: fix(spec): fold agent `knowledge.topics` into `sources` at parse; mark unenforced AI config experimental (#1891, #1893) + + Two liveness-audit closeouts (umbrella #1878): + + - **`AIKnowledgeSchema`** now folds the deprecated `topics` alias into the + canonical `sources` at parse time (canonical wins; alias dropped from the + output — mirrors the `visibleWhen` normalization, ADR-0089 D2). Authoring + `topics` was a silent no-op: the renderer only reads `sources`. The schema's + JSDoc example now shows `sources`. + - **Author-facing experimental markers** added to config that is parsed but has + no runtime consumer, matching the liveness ledger (ADR-0078): agent + `memory` / `guardrails` / `structuredOutput` / `lifecycle`, and tool + `outputSchema` (keys folded into the LLM-facing description only — no output + validation). + + Reference docs regenerated. No parse-acceptance change; `Agent`'s inferred + output type no longer carries `knowledge.topics` (input still accepts it). + +- 33f5e23: feat(lint): `validate-ai-surface-affinity` — skill ↔ agent surface affinity is now linted (#3820) + + An agent binds a product surface (`'ask'` | `'build'`, ADR-0063 §1) and a skill + declares which surface it belongs to (`'ask'` | `'build'` | `'both'`, §3). The + runtime refuses an incompatible binding with a **load error at chat time** — + after parse, validate, and deploy all passed cleanly. The new rule reports that + contradiction statically, and joins `REFERENCE_INTEGRITY_RULES`, so + `objectstack validate`, `lint`, and `compile` all pick it up with no CLI + changes. + + Scope is deliberately narrow (zero false positives by construction): only + bindings where **both** the agent and the skill are declared in the same stack + are checked. `agent.skills[]` names that don't resolve in-stack (kernel skills + are runtime-registered and statically invisible) are skipped — resolving those + namespaces is #3820 D0/D2, decided by ADR-0109 (Proposed). + + The spec side is doc-truth only, no schema shape changes: + + - `stack.agents` is documented as **platform-internal** (ADR-0063 §2 — the + kernel ships exactly two agents; third parties extend via skills), replacing + prose that still described the withdrawn ADR-0040 per-app-copilot model. + - `stack.tools` is documented as declaration-only pending the ADR-0109 tool + authoring model. + - `app.defaultAgent` is re-documented as a surface-binding knob (`'ask'` + implicit / `'build'` for authoring surfaces), not a custom-agent slot. + - `SkillSchema` now states that a per-skill `permissions` field deliberately + does not exist (ADR-0049) — authoring one is silently stripped; access is + gated by `agent.access` / `agent.permissions` and per-tool authz. + +- 1986594: feat(analytics): honour widget `dateGranularity`, `sortBy`/`sortOrder`, and `limit` in the dataset query (#3588) + + Three presentation options were accepted by the metadata layer and then dropped + by the analytics query builder. They reached no SQL, produced no error, and the + only way to notice was to read the `sql` a dataset response echoes — so a + dashboard could declare `dateGranularity: 'month'` and quietly render one bar + per record. + + - **`dateGranularity` now buckets.** `DatasetSelection` gained an optional + `dateGranularity`, applied to every selected `date` dimension. Precedence per + dimension: an explicit `timeDimensions` granularity, then the selection's, + then the dataset dimension's own default. A widget can bucket a trend by month + without the dataset committing every other consumer to that granularity. + - **`order` / `limit` / `offset` now apply on every path.** They are applied to + the ASSEMBLED grid — after measure-scoped sub-queries merge, after `compareTo` + columns attach, and after derived measures are computed — so a derived measure + is a valid sort key and the ObjectQL aggregate path (which has no ordering + grammar, and which native SQL hands every date-bucketed query to) orders + identically to native SQL. A single-query selection still pushes the window + down into the statement. An `order` key that names nothing the selection + projects is now rejected (400) rather than silently ignored. + - **`limit` is deterministic.** Without an `order`, a limit orders by the + selected dimensions first, so it truncates a reproducible window instead of an + arbitrary subset. + - **Widget `options` is a contract again.** The four query-affecting keys + (`dateGranularity`, `sortBy`, `sortOrder`, `limit`) plus `stageOrder` are + declared on `DashboardWidgetOptionsSchema`, so a typo like `sortDirection` is + an author-time error. The bag stays open — renderer extras (`icon`, `columns`, + `striped`, …) pass through untouched. + + Two latent bugs surfaced while fixing the above and are fixed here too: + + - `order`/`limit` were forwarded to EVERY sub-query. A measure-scoped + supplementary query selects one measure, so an inherited `ORDER BY` named a + column it never selected, and an inherited `LIMIT` truncated it before the + merge — dropping rows from the assembled grid. Nothing hit this only because + nothing passed `order`. + - The `compareTo` pass built its query by hand and skipped granularity + resolution, so a month-bucketed primary grid was merged against raw-timestamp + comparison rows. No dimension key matched and every `__compare` + column came back empty. + + `ObjectQLStrategy` now also echoes a representative `sql` (with `date_trunc`, + `WHERE`, `ORDER BY`, and `LIMIT`; filter values parameterized, never inlined). + Previously the `sql` field simply vanished from the response whenever a query + was date-bucketed, leaving an author unable to tell "not implemented" from "this + strategy doesn't report". + +- 0bc685a: fix(approvals): return decision attachments as file values, not "[object Object]" (#3504) + + `sys_approval_action.attachments` is a `Field.file`, so the column **stores an + opaque `sys_file` id** (ADR-0104 D3 — the stored form of every media field). The + ObjectQL read path resolves that id into its expanded + `{ id, name, size, mimeType, url }` form on the way out. But `rowFromAction` + mapped the column with `.map(String)`, collapsing each expanded value to the + literal string `"[object Object]"`. Every `listActions` consumer (the approval + inbox timeline) then received garbage: the attachment chip had no filename and + its id was `"[object Object]"`, so opening it 404'd. + + - `ApprovalActionRow.attachments` is now `ApprovalActionAttachment[]` — the + expanded file value plus its id, so a consumer can label and open an + attachment without needing read access to the system `sys_file` object (which + regular approvers do not have). + - Three read forms are accepted: the expanded value (the normal case), a bare id + (nothing to expand it into — storage service absent, file not committed), and + a legacy inline blob written before file-as-reference (`file_id` / + `mime_type`), until the backfill converts it. The id test reuses the + platform's `isFileIdToken`, so this and the engine's read resolver cannot + disagree about what counts as an id. + - The decision _input_ (`ApprovalDecisionInput.attachments`) is unchanged — it + still takes fileId strings, which is also exactly what the column stores. Only + the read shape changed. + +- b949059: fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) + + The record lock is keyed on a **pending** `sys_approval_request`, and it could + not tell _the run that owns that request_ from _an unrelated user editing the + record_. So a flow that touched its own target record while its own approval was + still pending — a manual `resume` with no decision, or a node that writes the + record between opening the approval and the decision — died on its own + `RECORD_LOCKED`, and the record stayed locked behind the dead run. Recovery + existed (#3424 lets an admin `recall`/`reject` to release it) but nothing made it + self-healing. + + Both halves are now closed. + + **Prevention — the owning run may write its own record.** The automation engine + stamps `flowRunId` onto the run context at setup, alongside `runAs`, and it + travels with every data node's ObjectQL context into `ctx.provenance`. The lock + hook exempts a write whose `flowRunId` matches the pending request's `flow_run_id`. + It is keyed on run identity rather than elevation on purpose: a `runAs:'user'` + run stays fully RLS-scoped while it writes. `flowRunId` is pure provenance — + server-constructed like `isSystem`, never client-supplied, evaluated by no + security middleware, and the only write it permits is to the one record its own + run already holds a pending request against. + + **Recovery — a sweep releases records held by runs that died anyway.** A pending + request whose owning run has reached a terminal state (`completed`, `failed`, + `cancelled`, `timed_out`) can never be decided, so it is finalised as `recalled` + — releasing the lock — and audited under the reserved actor `system:dead-run` + with the run and its status in the comment, so it is never mistaken for a + submitter's withdrawal. It runs on the existing approvals sweep clock, which also + covers the case no in-band handler can: a run killed by a process crash. + + The sweep is fail-safe by construction. It acts only on an explicit terminal + status from a closed set; `paused` (the normal state of a live approval), + `running`, an unrecognised status, an unknown run, a `getRun` that throws, and a + deployment with no automation engine are all read as "still alive". The failure + mode is "a dead run's lock survives until an admin recalls it" — today's + behaviour — never "a live approval is destroyed". + + Also fixes `AutomationEngine.getRun`, which returned the **first** log entry for + a run id rather than the latest. A run that pauses and later finishes records two + entries under one id, so every suspend-then-finish run — every approval, screen + and wait flow — reported itself as `paused` forever, both on the Runs + observability surface and to this sweep. + + One shape was left out here and closed separately in #3712: a `runAs:'user'` run + with no trigger user (a schedule) resolved no ObjectQL context at all, so it + carried no `flowRunId` and stayed subject to the lock. It now passes a + provenance-only context — the run id and nothing the security middleware keys on + — so it is attributable without acquiring a principal, and its documented + unscoped posture (#1888) is unchanged. + +- be1c52c: fix(approvals): admin override for a request routed to an unstaffed approver (#3424) + + An `approval` node routed to a `position` (or `team`/`department`) with **no + holders** resolved to only the unresolvable `position:` literal in + `pending_approvers` — no concrete user was in the slate. Every normal + `decide` / `reassign` / `recall` then returned `FORBIDDEN` (not a pending + approver) and, with `lockRecord`, the target record stayed `RECORD_LOCKED` + forever: a data-availability dead-end with no in-product recovery (the only exit + was editing the DB by hand). Very easy to hit in fresh/demo orgs (positions + seeded, holders not) and whenever a role is vacated in production. + + A **platform or tenant admin** — the same posture the engine's superuser bypass + already trusts — may now act on any _pending_ request to release it: **approve, + reject, reassign** it to a real approver, or **recall** it. The override finalizes + the request (which releases the record lock, keyed on a pending request); a + tenant admin's authority is org-scoped, a platform admin's is not, and the + decision is audited under the admin's own id. An admin approval is authoritative, + finalizing the node even under `unanimous` / `quorum` / `per_group` rather than + counting as one vote among the (empty) slate. + + - `sys_approval_request.viewer` gains `can_override` (server-computed): true for a + privileged admin on a pending request. The `approve` / `reject` / `reassign` + declared actions OR it into their `visible` gate, so the console surfaces the + recovery path without a hand-wired button. Existing approver/submitter gating is + unchanged. + - `openNodeRequest` now logs a loud warning when a node resolves to **no concrete + approver**, so the misconfiguration is visible instead of silently locking the + record. The literal-fallback behavior (kept for 15.x slot back-compat) is + otherwise unchanged. + +- c5ff96d: fix(approvals): a schedule-triggered run can write its own locked record (#3712) + + #3456 let the run that opened a pending approval write its own target record, + keyed on `flowRunId`. It worked for every run that resolves an identity and + missed the one that doesn't: an effective `runAs:'user'` run with **no trigger + user** — a schedule being the canonical case — passed no ObjectQL context at + all, so nothing carried the run id and the run still died on its own + `RECORD_LOCKED`. + + The blocker was never the lock. It was that "no identity" and "no context" were + the same thing on the wire, so a run could not say _who it was_ without also + claiming _what it was allowed to do_. + + **A run with no principal now passes provenance alone.** + `resolveRunDataContext` returns `{ flowRunId }` — no `userId`, no `positions`, + no `permissions`, not even `isSystem: false`. Every principal gate keys on one + of those fields (the elevation short-circuit on `isSystem`, the ADR-0103 + engine-owned write guard and the ADR-0090 D12 delegated-admin gate on `userId`, + the empty-principal fall-open on all three), so this context authorizes + **identically to no context at all**. The run keeps the documented #1888 + unscoped posture, its loud `[runAs]` warning, and the + `flow-schedule-runas-unscoped` build-time lint. Nothing about what it may touch + changed — only that it can now be attributed. + + **Provenance moved out of the hook session, into `ctx.provenance`.** `session` + answers _who is calling_ and is absent when no identity envelope was supplied — + a distinction real gates depend on (the attachment access gate skips bare-kernel + writes on exactly that test). Folding a run id into `session` would have forced + an identity-less run to present an empty session, silently turning "no caller" + into "an anonymous caller" and narrowing the #1888 fail-open for attachments + alone. `HookContext.provenance.flowRunId` says what produced the write; the + approvals lock reads it there. + + Also relaxes `BaseEngineOptionsSchema.context` to a partial envelope + (`ExecutionContextInput`). `positions`/`permissions`/`isSystem` carry parse-time + defaults, which made them _required_ on a caller-supplied option and asserted + something untrue — that every data-engine context carries a principal. Callers + have always passed slices (`{ isSystem: true }` for a system read); the type now + says so. + + Migration: nothing to change unless you read the run id inside a hook. If you + wrote `ctx.session.flowRunId`, read `ctx.provenance.flowRunId` instead — the + field never shipped under the old name. + +- 84e7be9: feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807) + + `per_group` (会签) requests now carry `pending_approver_groups` on the + enriched row — a map from each still-pending approver id to the group key(s) + it fills (e.g. `{ "u_devadmin": ["finance", "legal"] }`). A client can label + each "waiting on" chip with the group it represents instead of showing + duplicate, context-free names. + + - Resolved in `attachDecisionProgress` from the same open-time + `__approverGroups` snapshot the `decision_progress` groups already use, so + the two never disagree. + - Only the **pending** slots are mapped (a resolved approver has left + `pending_approvers`), and **synthetic** (unnamed, `#N`) group keys are + dropped — a `· #0` sub-tag would be noise. + - Absent for non-`per_group` behaviors. Display-only; the engine's + finalization tally stays authoritative. + - Added to the `ApprovalRequestRow` contract in `@objectstack/spec`. + +- debc23a: feat(approvals): enrich inbox rows with `payload_labels` (snapshot field labels) + + The approvals inbox summary title-cased raw snapshot machine keys + (`assessment_status` → "Assessment Status") because the API sent no field + labels. `ApprovalService.enrichRows` now attaches `payload_labels` (snapshot + field key → the target object's field label), symmetric with the existing + `payload_display` (which resolves the values), and `ApprovalRequestRow` gains + the field. For a single-locale project the schema label is already the + localized string, so a client can render the human field name (e.g. "考核状态") + instead of a prettified English key. + +- 8f9689f: fix(spec): ratchet the authorable key surface — the one contract no witness watched (#3855) + + For a metadata-driven platform the third-party API is **what an author may + write**: the keys inside each schema. Nothing guarded them. + + The two existing witnesses look at the TypeScript surface instead: + + - `api-surface.json` records exported `name (kind)`. A key inside a schema is + not an export, so removing one never moves it. + - `api-surface-signatures.json` hashes each `defineX` factory's type — but via + `checker.typeToString()`, which prints a type _reference_ + (`z.input`) and never expands it structurally. Member-level + narrowing cannot reach the hash. + + `spec-changes.json` inherits the same blind spot: its `added`/`removed` arrays + are a diff of `api-surface.json`. + + So #3883 removed three authorable keys with every witness green — and #3733 did + the same **by accident**, when `dataQuality` / `cached` outlived their keys and + were silently stripped. ADR-0059 §5 deferred a deeper gate "until a narrowing + actually slips both". It has, twice. + + **New: `authorable-surface.json`**, a committed ratchet of all 8588 authorable + keys, derived from the same walk that already emits the JSON Schemas — one level + deeper, no new introspection. It distinguishes three states, because a tombstoned + key (`retiredKey()`) is `z.never()`, which Zod renders as `{ "not": {} }`: + + | State | Meaning | + | ----------- | ------------------------------------------------------------- | + | live | a normal property — the author may write it | + | `[RETIRED]` | present but unwritable, carrying its own upgrade prescription | + | absent | gone from the contract with nothing left to say | + + Three failure modes now fail the build, each verified non-vacuous by simulation: + + 1. **A key vanishes without a tombstone.** These schemas are not `.strict()`, so + Zod silently strips an unknown key: the author gets a clean parse and a + setting that never takes effect. The error spells out the retirement protocol. + 2. **A key is tombstoned with no registered migration.** The tombstone is audible + to whoever hits it, but the change documentation — `spec-changes.json`, the + generated upgrade guide, the `spec_changes` MCP tool, `os migrate meta` — is + still empty. Requires a D2 conversion / D3 chain entry naming the surface. + 3. **An addition is left uncommitted** (`--check`). An unrecorded key is + invisible to the ratchet forever after, since it can only detect the + disappearance of something it once saw. + + It runs in `check:docs` (unconditional, required — cannot go dormant) and as an + explicit `check:authorable-surface` step in `Check Generated Artifacts`, with the + paths filter widened in lockstep per that filter's own rule. + + Also corrects the `build-api-surface.ts` docblock, which advertised the signature + snapshot as answering "did the accepted authoring shape narrow?" — it does not, + and believing it did is what let this gap sit. Value-level narrowing (an enum + losing a member) remains ungated, per ADR-0059 §5's evidence gate. + +- 376a061: Surface the approval node's author-declared `decisionOutputs` keys on the request read as `ApprovalRequestRow.decision_outputs` (#3447 P2 UI enablement). The set varies per request (each node declares its own), so it rides the row rather than the object's static action params — a decision UI renders one input per key and POSTs `outputs` with the decision. +- 9ea2bc5: fix(docs): `FieldSchema.extend()` does not exist — the FAQ was recommending a call that throws + + #3882 brought `content/docs` under the example type-check gate and left an open + question: of the blocks still unmarked, is any of it **genuine rot** rather than a + fragment? Swept them. One real one: + + `content/docs/deployment/troubleshooting.mdx` answered _"How do I extend a + built-in schema?"_ with + + ```ts + const CustomFieldSchema = FieldSchema.extend({ … }); // TypeError + ``` + + `FieldSchema` carries a `.transform()` that lowers author-facing sugar at parse + time, which makes it a **`ZodPipe`, not a `ZodObject`** — `.extend` is `undefined` + on it (verified against the built package). Anyone following the FAQ got a + `not a function` throw. The example also used `z` without importing it. + + Rewritten to **compose** — parse with `FieldSchema`, validate your additions + alongside it — verified to both type-check and run. `.in.extend()` is mentioned in + prose as the merged-schema route, with the caveat that it skips the transform. + + **A divergence worth knowing about, found while fixing this.** The first fix used + `FieldSchema.in.extend(…)` in the checked block. It passed locally and failed in + CI with `Property 'in' does not exist on type 'ZodObject<…>'` — the two builds + emit **different declarations for the same source**: locally + `z.ZodPipe>`, in CI a plain `z.ZodObject`. The runtime is + unambiguous (`bound ZodPipe`, `.extend === undefined`, verified after a clean + `rm -rf dist && pnpm build`), so **CI's declaration contradicts the value it + describes** — `.extend()` type-checks there and throws at runtime. Probably + inference instability in the DTS bundling of these very large zod types; worth its + own investigation. The example now uses only `.parse()`, so it is correct under + either declaration and the doc is not hostage to which one you get. + + **The sweep's method is recorded in the gate's docstring**, because two traps make + a naive pass report a confident "nothing found": + + 1. **`tsc` stops after syntactic diagnostics** — it never reaches the semantic + pass. Marking all 780 blocks at once let ~200 broken fragments suppress + type-checking for every other block; the run came back with only TS1xxx codes, + which reads exactly like "no rot" and proves nothing. Verified by injecting a + deliberate type error and watching it go unreported. + 2. **Unimported type names resolve against the DOM lib.** `Plugin`, `Event`, + `Response`, `Storage` all exist there, so a block missing its import reports + _"'version' does not exist in type 'Plugin'"_ against `lib.dom`'s `Plugin` — + an artefact, not drift. Three of the strongest-looking candidates were this. + + After both corrections the remaining blocks are fragments, plus + `protocol/kernel/config-resolution.mdx`, whose aspirational snippets are already + labelled as design intent by a callout on the page. + +- a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839) + + A grouped row whose dimension value is empty now carries `null` for that + dimension no matter which way the aggregate ran. Downstream code can test the + empty bucket with a plain `value == null` again: charts render their own empty + label, drill-through on that bucket builds `field = null` and returns the rows + it should, and a dashboard no longer changes shape when the driver, the + granularity or the reference timezone changes. + + ### What was wrong + + `engine.aggregate` has two implementations of one feature. It pushes the + aggregate down as SQL when the driver advertises every requested granularity and + the reference timezone is UTC; otherwise it fetches rows and buckets them in JS. + The two disagreed about how to spell "empty": + + ``` + --- same dataset, same query, one row with a NULL value --- + pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …] + in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …] + ``` + + The measures were always right — only the key's type and literal differed — + which is why this went unnoticed for so long: every total reconciled. But the + engine picks a path per query, so the same data produced a different bucket key + on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise), + a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of + which bucket in memory unconditionally. + + It was never date-specific either. A plain `groupBy: ['stage']` over a NULL + column diverged the same way. + + Consumers are written against `null` — they check `== null` and supply their own + empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated + every one of them: it rendered a raw English debug string in the UI, and a drill + on the empty bucket compiled to `field = '(null)'` and matched nothing. + + The in-memory path's comment justified the string as staying "consistent with + the client `useReportData` hook". That hook was removed with ADR-0021, and the + literal never appeared in it. + + ### What changed + + - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key + the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A + null instant and an unparseable one still share one bucket, because SQL cannot + tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL). + - The internal composite bucket id is JSON-encoded, so the empty bucket stays + distinct from a row whose value is the literal string `"null"`. + - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The + empty bucket has no calendar span, so a drill on it opens the unscoped + superset instead of an invented bound — unchanged behavior, honest signature. + - The driver output contract in `@objectstack/spec` now states the rule: a row + with no value keys as `null`, never a sentinel. Propagating NULL through the + bucket expression is the whole of it; a driver only breaks it by adding a + `COALESCE`. + + ### Gates + + `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null + instant, because the divergence would have failed it for a reason it was not + about. Its fixture now has one, so the convergence is held in place — including + for out-of-tree drivers that run the check against themselves. + + Two fixes were needed to make that fixture meaningful: + + - The check folded bucket labels through `String(value)`, which turns SQL NULL + into `'null'` — a label a TEXT column can genuinely hold. A driver spelling + "empty" as a string could compare equal to one returning real NULL. The empty + bucket is now keyed out of band. + - Label sets were compared with `JSON.stringify`, which is sensitive to key + insertion order. Row order is not part of this contract and the two paths + naturally differ (SQL sorts its groups; the in-memory path emits first-seen + order), so a driver with entirely correct buckets could be reported as + disagreeing — with an empty diff message, since nothing actually differed. + The comparison is now order-insensitive. + + A new dogfood check covers the non-date half against real drivers: same dataset, + plain and date-bucketed `groupBy`, both paths, one key. + +- 5b89711: feat(spec,lint): freeze the `{current_user_id}` filter vocabulary and fail the build on unresolvable placeholders (#3574) + + A dashboard widget filtered on `{current_user}` rendered `0`. Not an error — a + zero, indistinguishable from a metric that is legitimately empty, with nothing + in the console or the server log. `service_dashboard.my_open_cases_by_priority` + in the HotCRM template had shipped broken this way since the day it was + written. + + The token had never been part of the contract. Date macros were frozen in + `date-macros.zod.ts` with a spec vocabulary, a lint-usable predicate, and a + single client resolver; `{current_user_id}` had only prose in an `app.zod.ts` + JSDoc and three ad-hoc client implementations that each handled one surface's + filter shape. Nothing could tell an author their token was wrong. + + - **`@objectstack/spec`** — new `data/context-tokens.zod.ts` freezing + `CONTEXT_TOKENS` (`current_user_id`, `current_org_id`) as the sibling of + `DATE_MACRO_TOKENS`, with `isContextToken` / `isKnownFilterToken` / + `classifyFilterToken` and a `CONTEXT_TOKEN_SUGGESTIONS` near-miss table. The + module documents what the tokens are _not_: presentation scope, never an + access boundary — that is RLS, which uses the unrelated `current_user.id` + expression root. + - **`@objectstack/lint`** — new `validateFilterTokens` (rule + `filter-token-unknown`, severity `error`). It walks `filter` / `filters` / + `runtimeFilter` subtrees across dashboards, objects, views, reports, + datasets, pages and apps, and reports any placeholder that resolves in + neither vocabulary. It scans for filter _keys_ rather than enumerating known + surfaces, so a new surface following the convention is covered the day it + ships — enumerating surfaces is how the dashboard was missed in the first + place. Navigation `recordId` / `params` are deliberately out of scope: they + resolve `AppContextSelector` ids, which are meaningless in a filter. + - **`@objectstack/cli`** — the gate runs in `os validate` and `os compile`. + + It is an error rather than a warning because of who authors this metadata. An + AI reads a query returning `0` as a correct answer and builds on it; its + correction loop is author → validate → fix, so a diagnostic only reaches it if + it can fail the build. The three spellings the suggestion table covers — + `{current_user}`, `{user_id}`, `{organization_id}` — are each correct + _somewhere else_ in the platform, which is exactly why authors reach for them. + + Also fixes a `ViewSchema` JSDoc example that documented `{user_id}`, a token + that resolves nowhere. + +- 763931e: feat(filters): evaluate `{filter-token}` placeholders server-side (#3582) + + Filter values travel as JSON, so a time- or user-scoped slice writes a + placeholder instead of code: + + ```ts + filter: { close_date: { $gte: '{current_year_start}' }, owner: '{current_user_id}' } + ``` + + The vocabulary has been in `@objectstack/spec` for a while (`date-macros.zod.ts`, + `context-tokens.zod.ts`) and `objectstack build` rejects tokens outside it + (#3574). What was missing is the half that _substitutes a value_: **nothing on + the server ever did**. A placeholder reached the driver as the literal string + `'{current_year_start}'`, compared as text, and matched nothing. + + That failure is invisible — an empty widget looks exactly like a metric that is + legitimately zero — so apps worked around it by computing dates at module load, + which freezes "this year" into the built artifact and quietly goes stale. + + **New: `resolveFilterTokens()` in `@objectstack/core`**, wired into the two + server-side seams every filter passes through: + + - **ObjectQL read path** — `find` / `findOne` / `count` / `aggregate`, so REST + queries, related lists, saved-view filters and flow `find_records` all resolve. + It runs before the middleware chain, so only author-supplied filters are + inspected; RLS/sharing filters are injected downstream from concrete values. + - **Analytics dataset executor** — a dataset's intrinsic `filter`, a widget's + `runtimeFilter`, measure-scoped filters, and time-dimension `dateRange`s. + This path needs its own call: `NativeSQLStrategy` compiles raw SQL and binds + comparands directly, so a dashboard widget never passes through `engine.find()`. + + Behavioural notes: + + - Date tokens resolve to ISO strings (`YYYY-MM-DD`, or a full timestamp for + `{now}` / `{N_hours_ago}` / `{N_minutes_ago}`). Turning that into a column's + on-disk form stays the driver's job (`SqlDriver.temporalFilterValue`), so + there is still exactly one source of truth for the storage convention. + - Calendar boundaries follow `ExecutionContext.timezone`; one instant is pinned + per filter tree, so a `>= {current_month_start}` / `< {next_month_start}` pair + can never straddle a boundary. + - `{current_org_id}` reads `ExecutionContext.tenantId`; `{current_user_id}` reads + `userId`. A request carrying neither now **throws** instead of resolving to + `null` — a null comparand degrades to `IS NULL` on most drivers and would hand + back the rows the filter was written to exclude. + - An unrecognised placeholder **throws**, carrying the near-miss fix + (`{current_user}` → `{current_user_id}`, `{this_quarter_start}` → + `{current_quarter_start}`). This matches what `objectstack build` already + enforces. Consequence, previously implicit and now load-bearing: a filter value + that is _entirely_ `{...}` is always read as a placeholder, so a literal value + of that shape is not expressible — rename the value. + + Also in this change: `notify` no longer sends the six-character string + `"undefined"` as an audience member. `to: ['{record.owner.manager}']` walks + `.manager` on a scalar foreign-key id, resolves to nothing, and `String(undefined)` + turned that into a phantom recipient — the emit "succeeded", addressed nobody, + and said nothing. Unresolved recipients are now dropped, and a node with no + recipient left fails naming the offending template and pointing at the start + node's `config.expand` (#3475), which does hydrate the relation. + +- de9af8a: fix(automation,objectql): a filter that loses a condition must not run (#3810) + + Three related holes, all of which end in "the query matched rows the author + excluded". + + **1. A flow filter could silently widen to match everything.** + + The flow template interpolator expresses "this token did not resolve" as + `undefined`. In a message that renders as empty text — harmless. In a FILTER it + removes the condition, and a removed condition matches MORE rows. When it was + the only condition, `{ owner: '{record.ownr}' }` became `{}`, and `{}` handed to + `deleteMany` is every row in the table. + + So one mistyped field name in a `delete_record` node silently emptied the + object. Reproduced with all four causes: a typo (`{record.ownr}`), an input the + run never received, a lookup hop (`{record.account.name}` — the trigger record + carries a scalar id), and a filter placeholder. + + `get_record` / `update_record` / `delete_record` now refuse to execute when + interpolation erased any authored condition, naming the offending template. The + guard keys on LOSS, not emptiness: an author who deliberately wrote no filter is + unaffected, and losing one of two conditions still fails, because widening from + "my open records" to "all open records" is the same class of bug. + + **2. Filter placeholders never reached the engine that resolves them.** + + `config.filter` is where two `{…}` dialects meet — the flow template dialect + (`{record.owner}`) and the filter placeholder dialect (`{current_year_start}`, + `{current_user_id}`, resolved by `resolveFilterTokens()`). Evaluation order + picked the winner by accident: the flow interpolator ran first, found no flow + variable by that name, and erased it. + + `interpolateFilter()` hands that position back to the dialect that owns it — a + whole-string token that no flow variable resolves and that IS a recognised + placeholder passes through verbatim for the engine to expand. Flow variables + keep precedence, so a template that works today cannot change meaning. + + **3. The engine resolved placeholders on reads but not on writes.** + + `resolveFilterTokens()` reached `find`/`findOne`/`count`/`aggregate` only. So + the SAME filter selected different rows depending on the verb: `find({ owner: +'{current_user_id}' })` matched the signed-in user's rows, while + `update`/`delete` compared the literal token text and matched none — a flow that + previewed with one and acted with the other operated on two different row sets. + This is the #3106 shape one layer down: the evaluator existed, only some call + sites reached it. + + `update` and `delete` now resolve too, BEFORE the by-id fast path claims a + scalar `where.id` (otherwise an unresolved `{current_user_id}` would be bound as + the primary key itself). Caller options are never mutated. + +- c4df271: chore(spec): mark FormView `buttons`/`defaults` live now the ObjectUI renderer folds them (#1894) + + The structured `FormViewSchema.buttons` (per-button `submit`/`cancel`/`reset` + visibility + label) and `defaults` (create-mode initial values) shipped under + the ADR-0078 escape hatch — declared, but carrying an `[EXPERIMENTAL — NOT +ENFORCED]` marker because no consumer read them yet. The ObjectUI `ObjectForm` + renderer now folds both onto the flat props it reads + (`showSubmit`/`submitText`/`showCancel`/`cancelText`/`showReset`/ + `initialValues`), so the escape-hatch marker is dropped and the two spec + liveness-ledger entries (`view.form.buttons`, `view.form.defaults`) flip + `experimental → live`. + + No shape or parse-behavior change — both keys were already accepted. This + closes the `view` half of the inverse-drift cleanup (renderers reading + undeclared props), umbrella #1878. + +- a41ba5c: chore(spec): enroll `report` and `dashboard` in the liveness GOVERNED set (#3462) + + Closes the systemic anti-drift gap for two more authorable UI types (umbrella + #1878). Both were registered/round-trippable but ungoverned, so their property + liveness wasn't CI-checked — the reason drifts like dashboard `title`↔`label` + and stale report `chart` config survived until an audit caught them. + + - Added `packages/spec/liveness/report.json` (20 live / 2 dead) and + `dashboard.json` (18 live / 2 dead), each property classified with an + objectui consumer reference. + - Re-verification corrected several stale 2026-06 audit findings against current + code: report `chart` is **live** (DatasetReportChart plots `chart.xAxis`/ + `yAxis` via `useDatasetRows`, #1890/#3441); dashboard `globalFilters`/ + `dateRange` are **live** (framework#2501); `title`↔`label` fixed (objectui#2806); + the ADR-0021 widget migration shipped (#3251). Only `aria`/`performance` remain + dead on each (perf `authorWarn`'d). + - Added both to `GOVERNED` in `check-liveness.mts`; the gate is green. Future + drift on these types is now a CI failure, not an audit finding. + + `webhook` (the third type in #3462) is deferred — it isn't a registered + metadata type; its enrollment rides with the disconnect decision in #3461. + + No spec shape/behavior change (ledger + gate config only). + +- 189854c: chore(spec,cli): enroll `webhook` in the liveness GOVERNED set (#3462) + + Closes the final third of #3462 (umbrella #1878) — `report` and `dashboard` + landed in #3474; `webhook` was deferred for two reasons, both handled here. + + - **Not a registered metadata type.** `webhook` is absent from the metadata-type + registry, so the gate can't resolve it via `getMetadataTypeSchema`. Registering + it would switch on Studio webhook CRUD, `saveMetaItem` overlay acceptance, and + diagnostics sweeping — the wrong move while the authoring surface is still + disconnected (below). Instead the gate resolves it through a small + `SPEC_ONLY_SCHEMAS` override in `check-liveness.mts` (consulted before the + registry): the gate only needs to **walk** the schema, not register it. + - **The whole authoring surface is dead (#3461).** Nothing materializes an + authored `webhooks:` entry (stack/connector) into a `sys_webhook` dispatcher + row — the runtime reads only admin-authored `sys_webhook` rows. So + `packages/spec/liveness/webhook.json` classifies all 16 authorable props + **dead** and `authentication` **experimental** (HMAC-`secret`-only, its + existing marker). Per-prop notes record which props a future materializer + (#3461 option A) could remap (e.g. `object`→`object_name`, `isActive`→`active`) + vs which have no sink anywhere — doubling as that mapping table. + - **Author-warning wired (`@objectstack/cli`).** Added + `{ type: 'webhook', key: 'webhooks' }` to `TYPE_COLLECTIONS` in + `lint-liveness-properties.ts`, so `os compile` now advises authors that + `webhooks:` is a silent no-op. The required `url` prop carries the single + warning per webhook (one heads-up per artifact, not one per dead prop); + `isActive` is left unmarked (default(true) boolean). + + This is enrollment only — it does **not** decide #3461's build-the-bridge vs + retire-the-surface question. When that lands, the mapped props flip to live (cite + the materializer) or the ledger is removed with the schema. No spec shape/behavior + change (ledger + gate/lint config only). + +- 0e3a226: fix(authz): widen the driver's native tenant scope to the membership union + under the `group` posture — ADR-0105 D2 finally reaches the wire (#3623) + + The Layer 0 wall correctly compiled `organization_id IN accessible_org_ids` + under `group`, but the ObjectQL engine also propagated the active-org + `tenantId` into `DriverOptions` unconditionally, and the SQL driver's native + scoping ANDed `organization_id = tenantId` under the union — collapsing every + group read back to active-org (isolated) reach. Found by the cloud-side + `ee-group-showcase` dogfood (cloud#880), the first end-to-end boot of `group` + against a real driver. + + - `DriverOptions.tenantIds` (spec): the union tenant access set. Drivers with + native scoping widen reads/updates/deletes/aggregates to `IN (...)`, + keeping the NULL-tenant global-row carve-out; inserts still stamp from + `tenantId` (the active organization is the write target, D5). Absent or + empty ⇒ equality fallback — fail toward isolation, never toward exposure. + - ObjectQL engine threads `ExecutionContext.accessible_org_ids` as + `tenantIds` when the tenancy posture is `group`, reported by a new + `setTenancyPostureProvider` seam. + - SecurityPlugin wires that provider at start — deliberately from the + enforcement layer, so the driver wall only widens while the Layer 0 union + wall enforces above it. Embeddings without plugin-security keep active-org + equality. + +- a8d1e24: feat(cli,spec): gate the whole declared surface for i18n, and translate inline object actions server-side (#3370) + + In a zh-CN workspace the platform chrome was localized while author-declared + labels leaked English — the approval drawer rendered **Approve / Reject / + Reassign** right beside the inbox's own 通过 / 拒绝. Two independent holes, both + closed here. + + **The lint gate could not see them.** `os lint`'s i18n coverage kept its own + walk of the metadata, separate from the one `os i18n extract` uses to scaffold + bundles, and the two had drifted: coverage only ever walked the _top-level_ + `actions` array, while `sys_approval_request` declares its decision actions + **inline on the object**. Those labels were extractable but ungated, so an + untranslated one could ship and no lint run would notice. Coverage now derives + its expected keys from `collectExpectedEntries()` — the extractor's walker — so + the gated surface and the scaffolded surface cannot disagree again. Newly gated + as a result: inline object actions, action `params` and `resultDialog` copy, + object-nested `listViews` (label / description / `emptyState`), object + `description`, field `help` / `placeholder`, and the `apps` / `dashboards` / + `pages` surfaces. Extract output is byte-identical — verified against the + committed plugin bundles. + + **It stays silent for projects that do not translate.** Which locales get + checked is the project's declaration, never an assumption: `os lint`, + `os i18n check` and `os i18n extract` now read the stack's own + `i18n.defaultLocale` / `i18n.supportedLocales`, falling back to the locales a + bundle already exists for, and finally to `en`. A project with neither is + checked against its default locale alone — which its inline labels already + satisfy — so it reports zero i18n issues. That also fixes a monolingual + _non-English_ project being told it owed `en` translations it never claimed to + speak. Locked by regression tests; the three bundled examples stay at 0 errors. + + **The server sent English regardless of locale.** `translateObject` walked an + object's `label` / `pluralLabel` / `description` / `fields` but never its inline + `actions`, so `GET /api/v1/meta/object/:name` returned the authored English + literals even though `@objectstack/plugin-approvals` ships `_actions` + translations for all eight decision actions in zh-CN / ja-JP / es-ES. The + Console compensated by re-resolving labels client-side against a separately + fetched bundle; every other consumer — mobile, plain HTTP, SDUI — rendered the + source language. It now runs inline actions through `translateAction`, without + stamping a synthetic `objectName` onto the response. + + Adds `os i18n extract --no-metadata-forms`. Whether the companion + `.metadata-forms.generated.ts` file is written was previously implicit: + every run emitted it, so `--check` demanded that file in packages that + deliberately do not commit one. The Studio metadata-form baseline is + registry-driven and identical for every stack, so exactly one package owns it + (`platform-objects`); a plugin translating only its own objects now opts out, + and its `--check` stops failing on a tree that is in sync. Defaults to emitting, + so `pnpm check:i18n` keeps covering all 8 platform bundles. + +- 81ce41a: feat(rest): `treatAsHistorical` import also preserves the original audit timeline (#3493) + + Follow-up to #3479/#3483. `treatAsHistorical` solved the FSM half — mid-lifecycle + rows are no longer rejected by `initialStates` — but the OTHER half of a historical + migration, preserving the original timeline, still didn't hold: an imported ticket + that closed in 2021 stored `updated_at` = the import day (and `updated_by` = the + importer), and a `writeMode: 'upsert'` refresh silently dropped business `readonly` + fields (`closed_at`, `resolved_by`). Reports, audit, and "recently modified" + sorting all came out wrong. + + Three layers were force-overwriting the timeline; all three now respect a single + new opt-in flag, `ExecutionContext.preserveAudit`, which `treatAsHistorical` sets + alongside `skipStateMachine`: + + - **spec**: `ExecutionContext.preserveAudit` (server-set only, never client-supplied) + and `DriverOptions.preserveAudit` (threaded to the driver's update stamp). + - **objectql** — the built-in audit hook (`plugin.ts`) now treats `updated_at` / + `updated_by` as CLIENT-PREFERRED (`?? now` / `?? userId`) under `preserveAudit`, + symmetric with how `created_at` / `created_by` already behave on insert; and the + static-`readonly` write strip (`stripReadonlyFields`) admits a WHITELIST — the + audit/timestamp family plus author-declared business `readonly` fields — so an + upsert refresh no longer drops them. + - **driver-sql** — the SQL `update` path keeps a supplied `updated_at` instead of + force-advancing it to `now` when `DriverOptions.preserveAudit` is set (fills-only- + empty, mirroring the insert stamp). + - **rest** — the import runner sets `preserveAudit` on the write context iff the + request opts into `treatAsHistorical`. + + Deliberately a WHITELIST, not the blanket `isSystem` exemption: platform-managed + `system` columns OUTSIDE the audit family (`organization_id` / tenancy, generated + columns) STAY stripped, so a historical import reinstates established facts without + becoming a backdoor to forge tenancy. Permissions / RLS / field-level security are + unaffected — this changes only which audit/readonly values the runtime overwrites, + never who may write the record. Fully opt-in: a normal write still auto-stamps + `updated_at`/`updated_by` and strips `readonly` exactly as before. The objectui + "Import as historical data" checkbox (objectui#2815) now drives both halves — no new + UI. + +- 85e1e4e: feat(rest): `treatAsHistorical` import option — skip the state machine for historical-data migration (#3479) + + Sibling of #3433 (seed exemption), one entry point over. #3165's `initialStates` enforced + the FSM entry point on every INSERT, so importing established historical facts — + a batch of already-`closed` tickets, `closed_won` deals, `completed` projects — + was rejected row-by-row with `invalid_initial_state`, blocking the core + data-migration path. Unlike the seed case it was visible (per-row errors), but it + still functionally blocked a legitimate use. + + - **spec**: `ExecutionContext.skipStateMachine` — a general, server-set flag (the + seed-specific `seedReplay`'s sibling) that skips the `state_machine` rule for a + write; `ImportRequestSchema.treatAsHistorical` (default `false`) — the user-facing + import option. + - **objectql**: the engine now skips the state machine for `seedReplay` OR + `skipStateMachine` (one helper), covering both seed replay and historical import. + - **rest**: the import runner sets `skipStateMachine` on the write context iff the + request opts into `treatAsHistorical`; default off, so a normal import still walks + the FSM (the strict behavior is the default). Import **undo** now also carries + `skipStateMachine`, since restoring a prior snapshot re-writes an earlier state + that need not be a legal transition from where the row is now. + - **platform-objects**: `sys_import_job.treat_as_historical` audit column (additive). + + Scope is identical to the seed exemption: ONLY the `state_machine` rule is skipped; + field shape, `format`, `cross_field`, `script` all still run. The objectui import + wizard checkbox is a separate follow-up. + +- dac6a08: feat(driver-sql)!: make index drift visible to `os migrate plan` — no more silent DDL at boot (#3728) + + The #3696 unique-scope migration converged **in place**: `syncTableIndexes` ran a + `DROP` + `CREATE UNIQUE INDEX` during `initObjects`, in every environment, + leaving one log line behind. `os migrate plan` showed nothing, because + `detectManagedDrift` was column-only — `ManagedDriftOp` had no index dimension at + all. An operator who wanted to review the DDL before it reached their database + had no way to, and a managed schema was being auto-altered in production, which + the #2186 contract explicitly forbids. + + Index drift is now a first-class dimension, reconciled through the same path as + column drift: + + - **`syncTableIndexes` is additive only.** It creates indexes; it never drops or + rewrites one. `dropLegacyGlobalUniques` is gone. + - **New `DriftOp` variants** — `replace_unique_index` (safe: retire the legacy + platform-wide unique in favour of the tenant composite), `create_index` (safe), + `recreate_index` (needs-confirm; destructive when it tightens to `UNIQUE`), and + `drop_index` (destructive). + - **`detectManagedDrift` reports them**, `os migrate plan` renders them (index + ops display as `table [index_name]`), and `os migrate apply` executes them. + Index DDL is portable, so it applies directly on every dialect — no SQLite + table rebuild. + - **`replace_unique_index` creates before it drops**, so uniqueness is never + unenforced mid-migration and a failed create leaves the schema untouched. + - **Declared `indexes[]` drift is covered too**: an index metadata declares but + the database lacks, and one whose definition no longer matches the declaration + (the additive sync skips those by name, so they could never self-heal). + - **Orphan detection is limited to ObjectStack's own generated naming** + (`uniq_…` / `idx_…`, plus the pre-#3696 `
__unique` knex + spelling). A hand-rolled operational index is never reported as drift and + `--allow-destructive` will not delete it. + + **Behaviour change.** Boot no longer rewrites the index unconditionally. Dev + (`autoMigrate: 'safe'`, what `os dev` / `os serve` use) still self-heals on + restart, so local workflows are unchanged. Production now **warns** with an + actionable `os migrate` hint and leaves the schema alone — the deployment stays + on the legacy global unique (multi-tenant inserts still collide) until someone + runs `os migrate apply`. That is the deliberate trade: a visible, pre-inspectable + migration instead of an invisible one. + + Also fixed: `managedObjectIndexes` was never cleared when an object dropped its + `indexes[]`, so drift detection kept expecting an index nobody declared. + + `SchemaDiffEntryKind` gains `index_mismatch` and `unmapped_index`. + +- d77d1b7: fix(spec): the liveness gate's stale-evidence check was ~100% false positives — and it was burying a real one + + The check was one line: + + ```ts + const file = String(led.evidence).split(':')[0]; + if (/\//.test(file) && !existsSync(join(repoRoot, file))) → flag + ``` + + i.e. it assumed every `evidence` string is exactly `path/to/file.ts:123`. Almost + none are — they carry prose (`packages/spec/src/stack.zod.ts (mergeActionsIntoObjects +stable-sorts each group)`), multiple pointers, or a cross-repo attribution + (`objectui: packages/app-shell/…`). Taking everything before the first colon + turns that prose into the "filename", which never exists. + + Result: **48 of 227 entries flagged, every one a parse artefact or a deliberate + cross-repo pointer.** A permanently non-empty, ~100%-false warning is a warning + nobody reads — which is exactly how the one genuine rot in that list went + unnoticed: + + - **`object.enable.clone`** cited `packages/objectql/src/protocol.ts:2259`. That + file no longer exists; `cloneData()`'s `enable.clone` gate moved to + `packages/metadata-protocol/src/protocol.ts:2938`. The claim stayed true, the + pointer rotted, and the check that exists to catch precisely this could not be + heard over the noise. Pointer repaired and dated. + + **New `evidence.mts`** extracts repo-rooted paths properly and honours the + cross-repo attribution entries already write in prose: + + - a realm marker (`objectui`, `cloud`, `ee`) attributes the paths after it, up to + the next clause boundary, so one string can cite both repos; `framework` + switches back explicitly; + - `packages/services/service-ai/…` is always foreign — the closed cloud runtime, + the one sibling missing from this repo's `packages/services/`; + - non-repo-rooted tokens (`app-shell/MetadataProvider.tsx`, + `action-button/-group`) read as prose, neither resolved nor reported. + + The gate now resolves **156 evidence paths** against the checkout, attributes 36 + to another repo, and reports **zero** stale — down from 48 warnings that said + nothing. Each run prints the two counts, so the check degrading to "extracts + nothing" is visible rather than silently green (a unit test asserts it too). + + Also updates the ledger README, whose advice to write objectui paths "as prose to + avoid false stale-flags" was a workaround for this bug: write the full path with + a realm prefix instead. + +- 5b79a34: feat(spec): register the 13 orphan dogfood proofs — five advance the ADR-0054 ratchet, eight say why they can't + + The gate flagged 13 `@proof:` tags under `packages/qa/dogfood/test/**` that no + class in `proof-registry.mts` claimed. Silencing that warning is trivial and + worthless; the useful question is the one ADR-0054 §3 actually asks: **is there + an authorable property whose `live` status this proof gates?** Each of the 13 was + re-read against that. + + **Five had one, and are now BOUND** — a `live` classification on these entries + requires its proof, so the ratchet advances from 10 bound paths to 17: + + | Proof | Now gates | Why it qualifies | + | ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `attachments-permission-matrix` | `object.enable.files` | the #2727 opt-in gate proven in BOTH directions — the fixture carries a deliberate non-declaring object (`att_nofiles`) that must be refused 403 FILES_DISABLED | + | `showcase-d3-d4-capabilities` | `permission.rowLevelSecurity.check` | authors `check: 'owner == current_user.email'` and proves the write POST-image is validated (distinct from `using`, which filters the pre-image) | + | `showcase-scope-depth` | `permission.objects.readScope` | authors `unit` / `unit_and_below` profiles and proves the owner-match widens, with cross-BU still isolated | + | `owner-anchor-and-bulk-writes` | `permission.objects.modifyAllRecords` | member denied the transfer, privileged caller allowed — both directions | + | `semantic-roles-served` | `object.highlightFields`, `.stageField`, `.fieldGroups` | asserts all three survive defineStack → artifact → registry → REST verbatim (incl. `stageField: false` as a strict false) | + + **Eight do not, and record why** rather than faking a binding — the shape the + registry already used for `permission-set-projection`: + + - `flow-runas-schedule` and `showcase-scope-depth-fallback` guard properties + (`flow.runAs`, `permission.objects.readScope`) that are _already_ bound to a + sibling proof. A ledger entry carries one `proof` ref, so a second gate on the + same property is not representable — they run unconditionally instead. + - `me-apps-and-everyone-baseline` enforces `app.requiredPermissions` / + `app.tabPermissions`; `app` is not a governed type yet. Bind when it lands. + - `showcase-agent-intersection` / `showcase-agent-scope-ceiling` guard runtime + principal-resolution invariants (`onBehalfOf`, OAuth scope → ceiling set), not + authorable metadata. + - `showcase-bu-hierarchy-sharing` / `showcase-declarative-rbac-seeding` act on + stack-level `roles`/`sharingRules`, not a per-type property surface. + - `showcase-permission-zoo` is a breadth guard over the whole ADR-0090 surface; + binding it to any one entry would misrepresent both. + + **One deliberate non-binding worth naming.** `owner-anchor-and-bulk-writes` binds + `modifyAllRecords`, not the sibling `allowTransfer` — the proof only _mentions_ + `allowTransfer` in a comment and never authors it. Binding a property a proof + does not exercise is the same false comfort as a preview renderer standing in for + a runtime consumer, which is the error this ledger spent #3686 unwinding. + + Also verified the bound proofs actually run: the only `skipIf` among them covers + `attachments-permission-matrix`'s enterprise cross-tenant block, not the + FILES_DISABLED assertion the binding rests on. + + The gate now runs with **zero warnings** — the orphan list joins the + stale-evidence list at empty, so both mean something again. The ledger README's + ratchet table was itself stale (5 classes listed, 10 bound) and is now complete, + with the unbound set and its reasons alongside. + +- c757854: feat(spec): `verifiedAt` re-verification clock on liveness entries + two entries re-verified against objectui (#3714 follow-up) + + A ledger entry is a claim with a timestamp, and **twice** now one has been + falsified by code moving under it — `flow.status` (#3711) and `action.undoable` + (#3714), both _understated_, both found only because a sweep aimed at the + opposite failure walked past them. Nothing in the gate asked how old a claim + was, so a stale entry stayed invisible until someone tripped over it. + + **`verifiedAt`.** Ledger entries may now carry `"verifiedAt": "YYYY-MM-DD"` — + the date a human last closed the call graph. The asymmetry is the design: + + - **Age never fails CI.** Re-verification is a worklist, not a merge gate. Every + run prints one summary line; `pnpm check:liveness --stale-verification[=days]` + prints the worklist (stale oldest-first, then undated). Default 180 days. + - **A malformed or future-dated value DOES fail CI.** A date the parser can't + read would silently exempt that entry from every staleness window — the same + silent-no-op shape this ledger exists to catch. Also rejects calendar-invalid + dates, since `new Date('2026-02-30')` rolls over to March 2 rather than + throwing. + + Currently 2 of 401 entries are dated. The rest predate the field and report as + undated; date them as you re-verify rather than back-filling guesses. + + **Two entries re-verified against objectui `732b1bf`:** + + - `action.undoable` — both readers stand, and the call graph now closes end to + end in the evidence: the two `if (action.undoable …)` gates build + `result.undo`; `ActionRunner.ts:640-643` pushes it onto `globalUndoManager` + and passes `undo` to the toast handler; the toast's Undo button runs + `undoCtl.undo()` → `useGlobalUndo` → `UndoManager` → `dataSource`. The cited + `RecordDetailView` line numbers had already drifted (545→573, 404→432) in the + day between the issue being filed and this pass — hence the pinned sha. + - `action.type` — `api` → `executeAPI`, `form` → `executeForm`, both real. + + **Docs correction (`content/docs/ui/actions.mdx`).** That page told authors the + schema's `api` and `form` types have "no runtime executor / renderer today — + stick to the four above." Both have had executors in objectui's `ActionRunner` + for some time, and the ledger's own `action.type` entry recorded `form` as live + since #2377. Same understatement shape as #3714, one page over. Both types now + have table rows; the callout keeps the parts that are true (`shortcut` and + `bulkEnabled` really are unwired) and links the ledger. `undoable` also joins + the UX property list, which is the author-facing payoff of #3714. + +- 0fc6219: feat(spec): the example type-check gate now covers `content/docs`, not just `skills/` + + `check:skill-examples` compiles the TypeScript in prose against the built spec, so + an example that stops compiling fails CI instead of quietly teaching code that no + longer works. It does its job — it caught the broken `defineTool` example when + `tool.requiresConfirmation` was removed (#3715). + + But it only ever walked `skills/`: + + | Tree | files with `ts` blocks | compiled | + | ------------------------------ | ---------------------- | -------- | + | `skills/` | 9 | **9** | + | `content/docs/` (hand-written) | 124 | **0** | + + The identical break in a docs page would have shipped. Docs examples are copied + verbatim by humans and AI exactly like skill examples, so a gate covering a + fraction of the surface it appears to cover reads as coverage — the same shape as + the stale-evidence and orphan-proof warnings fixed in #3857 / #3868. + + The walker is now a `SOURCE_ROOTS` list, and this lands the first batch: **164 + docs blocks across 63 pages, taking the gate from 32 to 196 checked examples**. + `content/docs/references/` is excluded — `build-docs.ts` regenerates it from the + schemas, so it cannot drift independently of its source. + + **The marker is now per-format.** MDX has no HTML comments: `` in + a `.mdx` fails the fumadocs build outright — _"Unexpected character `!`… to create + a comment in MDX, use `{/_ text _/}`"_. Caught by building the docs site, after + the first attempt broke 60+ pages. `skills/**/*.md` keeps ``; + `content/docs/**/*.mdx` uses `{/* os:check */}`. Both spellings are recognised for + **orphan** detection, so a wrong-format marker fails loudly rather than silently + checking nothing — the existing guard's philosophy, extended to the new failure + mode this change introduces. + + The batch was measured rather than guessed: marking all 780 docs blocks and + compiling showed which are self-contained. One subtlety worth recording — a block + that "passes" inside a 780-file program can be leaning on globals declared by + _other_ blocks (a file with no import/export is a global script), so the set was + converged by recompiling and dropping newly-failing blocks until green. 164 stand + on their own. + + The remaining blocks are mostly fragments (a `columns: [...]` subtree), which the + gate's opt-in design already anticipates. Whether any are genuine rot is worth a + follow-up now that the machinery reaches them. + +- f07808c: feat(spec): reject a `body` on a non-script action — it would never run (#3530) + + `Action.body` is documented as "only meaningful when `type === 'script'`", but + nothing enforced it. A `type: 'modal'` action authored with `params` and a + `body` — expecting the modal to collect the input and the body to write the + record on submit — passed validation, passed shape tests, and shipped a button + that opened a modal and silently wrote nothing. Non-script types all dispatch on + `target` (the page to open, the URL, the flow, the endpoint); there is no point + at which a renderer would invoke the body. + + This is the same invisible-failure shape as the existing rule that rejects a + `script` action with neither `body` nor `target` (#2169), so it is enforced the + same way: a parse-time error that names the fix — `type: 'script'` collects the + same `params` and does run the body, and a modal that only opens a page should + drop the `body` and keep `target` naming the page. + +- 32ff033: docs(spec): correct ReportChart `xAxis`/`yAxis` semantics; mark dead report surface (#1890) + + Closes the report residual of the ADR-0021 analytics migration (#1890). The + dataset-bound report chart already renders — objectui's `DatasetReportRenderer` + plots `chart.xAxis`/`yAxis` as the bound dataset's **dimension**/**measure** via + `useDatasetRows`, and the Studio `ReportDefaultInspector` picks them from the + dataset's dimension/measure catalogs — but the spec `.describe()` still called + them raw "Grouping field" / "Summary field", misleading an author (or AI) into + naming object fields instead of dataset dimension/measure names. + + - `ReportChart.xAxis`/`yAxis` describe now states they are dataset + dimension/measure names (matching the live renderer + inspector). + - `ReportChart.groupBy` marked `[EXPERIMENTAL — not enforced]` — the + dataset-bound renderer plots a single `xAxis`×`yAxis` series and never reads + it; only the legacy `ReportViewer` fallback did. + - `ReportColumnSchema` / `ReportGroupingSchema` marked `@deprecated` — the + single-form report shape expresses columns/grouping as dataset + measure/dimension name arrays, so these objects are unreferenced; they remain + only as public type exports (objectui re-exports them) pending a governed + prune. + + Docs regenerated (`ui/report.mdx`). No shape or parse-behavior change; no + export removed. + +- abceb0d: fix(seed-loader): support a composite `externalId` so join-table seeds dedupe on replay (#3434) + + A junction / join table has no single-field natural key — the PAIR of its + foreign keys is what's unique — so its seed could only run `mode: 'insert'`, + which re-inserts every row on each replay boot with no existing-row check + (`decideWriteAction`'s `insert` case returns `insert` unconditionally). The + table duplicated on every restart: the showcase `showcase_project_membership` + fixture (3 rows) grew 3 → 6 → 9. It was masked until #3415 let the master-detail + parents seed at all. + + - `SeedSchema.externalId` now accepts a **list** of field names + (`externalId: ['team', 'project']`) in addition to a single field name, + declaring a composite natural key. Default stays `'name'`. + - `SeedLoaderService` builds the uniqueness key from all listed fields (joined + with a `\u0000` separator that can't occur in a natural-key value). Reference + key fields are compared by their RESOLVED parent ids — which the existing DB + row already stores — so a composite of foreign keys matches across restarts. + A partial key (any component absent) is treated as no key, falling back to + insert, exactly as a missing single-field key already did. + - A composite-key target does not participate in single-value reference + resolution (a reference is one natural-key string), so such objects keep the + `'name'` default when referenced by another dataset. + + The showcase membership fixture switches to `mode: 'ignore'` + + `externalId: ['team', 'project']`, so replay boots leave the three rows + untouched instead of duplicating them. + +- 0c302a7: Exempt curated seed writes from `state_machine` validation (#3433). + + A seed is a snapshot of established facts — a project already `completed`, an + opportunity already `closed_won` — not a record walking its lifecycle. But once + an object declared `state_machine.initialStates` (#3165), the write path enforced + the FSM entry point on **every** insert, so seed replay silently rejected every + mid-lifecycle row and cascaded its master-detail children. That is the "installed + but no data" failure for the showcase board (1 of 5 projects), and it would hit + every marketplace template (a `closed_won` opportunity, a `closed` case) plus the + rehydrate-heal and per-org replay paths. + + `SeedLoaderService` now marks its writes with a server-set `ExecutionContext.seedReplay` + flag; the engine passes `skipStateMachine` to the rule evaluator for those writes, + which skips the `state_machine` rule on both insert (`initialStates`) and update + (transitions). The exemption is scoped to `state_machine` only — a seed must still + satisfy every other validation (`format`, `cross_field`, `script`, `json_schema`, + `conditional`). Because all seed paths funnel through `SeedLoaderService.SEED_OPTIONS`, + the fix covers boot inline seed, marketplace install/heal, and per-org replay at once. + + The showcase project seed drops its three-phase FSM-walk workaround (#3415) and + seeds each project directly at its real status again. + +- 6633337: fix(service-storage): emit the declared success envelope on all eight routes (#3689) + + #3675 moved the **error** bodies of the autonomously-mounted `/api/v1/storage/*` + routes into the declared `{ success: false, error: { code, message } }` + envelope and deliberately stopped there: unlike the errors, the success bodies + were not an additive fix. They were three shapes, none of them carrying the + `success` flag `BaseResponseSchema` declares and + `ObjectStackClient.unwrapResponse` keys on — + + | Route(s) | Was | Now | + | ---------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------- | + | the six upload routes (`/upload/presigned`, `/upload/complete`, `/upload/chunked`, `…/chunk/:i`, `…/complete`, `…/progress`) | `{ data: {…} }` | `{ success: true, data: {…} }` | + | `GET /files/:fileId/url` | `{ url }` | `{ success: true, data: { url } }` | + | `PUT /_local/raw/:token` | `{ ok: true, key }` | `{ success: true, data: { key } }` | + + — while `storage.zod.ts` declared every one of them as + `BaseResponseSchema.extend({ data })`, and `PresignedUrlResponse` and friends + are `z.infer`red from those schemas and published as the SDK's return types. + The declaration said `success: boolean`; the wire said nothing. It broke + nothing only because the storage SDK methods returned `res.json()` raw — + `any`, so TypeScript could not see the gap and nothing relied on the + declaration. That is the posture i18n was in before #3636, right up until + something did rely on it. + + **The payload moved on two routes, and that is the breaking part.** A direct + HTTP caller reading `body.url` from `GET /files/:fileId/url` must now read + `body.data.url`; one reading `body.ok`/`body.key` from the local adapter's + `PUT /_local/raw/:token` loopback must read `body.success`/`body.data.key`. + `ok` is dropped rather than kept beside `success` — it was a second, private + word for the same thing. The six upload routes are additive: callers already + destructure `.data`, and a new sibling key changes nothing. + + Every in-repo consumer was fixed first, so the two repos are not coupled by + merge order: + + - `client.storage.getDownloadUrl()` now reads through `unwrapResponse`, the + SDK's one standard envelope seam — which strips the envelope when present + and returns the body untouched when not, so a client either side of this + server change resolves the same URL. The other storage methods hand back the + whole envelope by design and were already correct. + - The console's two attachment openers (`RecordAttachmentsPanel`, + `ApprovalsInboxPage`) already read `body?.url ?? body?.data?.url`; objectui + gains tests pinning that tolerance as deliberate. + + Two schemas that were missing are now declared — `FileDownloadUrlResponse` and + `RawUploadResponse` — and `getDownloadUrl` joins `StorageApiContracts`, which + it had never been in. That absence is how its shape drifted outside the + envelope unnoticed. The two `_local/raw/:token` routes stay out of the + registry on purpose: they are the local adapter's own presign loopback, + ledgered `server-only` and addressed as an opaque signed URL rather than as an + API. + + `success-envelope.conformance.test.ts` holds the new shape in place the way + `error-envelope.conformance.test.ts` holds the error one: every route is + driven and its body parsed against the **declared schema** it answers to — not + a restatement — the retired shapes are asserted dead, and the module source is + scanned so a new route cannot bypass the `sendOk` helper. As with #3675, the + route ledgers cannot catch this class of drift: they audit which routes exist + and whether the SDK can address them, not what comes back. + +- cde1975: fix(dev): eliminate three fixed startup log warnings so official examples boot clean (#3420) + + `os dev` on the stock showcase printed three fixed noise sources on every boot, + with zero example-side changes — training users to ignore warnings. + + - **spec** — add a field-level `ackPlaintextMasking: true` opt-out for the + generic `password` author-time warning (ADR-0100). A deliberately-masked + field (like field-zoo's `f_password`) can now affirm intent instead of + printing an un-actionable "safe to ignore" on every boot; the warning text + points authors at the flag. + - **plugin-auth** — pass better-auth's documented + `silenceWarnings.oauthAuthServerConfig` to `oauthProvider(...)`. We already + mount the `/.well-known/oauth-authorization-server` documents ourselves at + the issuer root, so the plugin's "please ensure it exists" reminder was a + false positive (printed twice); silencing it removes both. + - **objectql** — route the Registry's re-register / package-overwrite lines + (normal rebuild / HMR / seed-replay paths) through a new debug-only + `SchemaRegistry.debug()` so they stay out of the default `info` boot log. Adds + a `logLevel` construction option (and matching `OS_REGISTRY_LOG` env var) so + the debug-gated housekeeping is discoverable for troubleshooting. + +- 0bc685a: fix(storage): downloads carry the real filename + content-type, not the URL token (#3504) + + A presigned download served the bytes as `application/octet-stream` with no + `Content-Disposition`, so a browser saved the file under the opaque URL token + (e.g. `eyJrIjoiYXR0YWNo…`) instead of its real name — an approval's + `signed-contract.pdf` downloaded as a nameless blob. + + - `IStorageService.getSignedUrl` / `getPresignedDownload` take an optional + `PresignedDownloadOptions` (`filename`, `contentType`, `disposition`). + - The REST download routes (`GET /storage/files/:id/url` and `/:id`) pass the + `sys_file` record's `name` + `mime_type`. + - The local adapter carries them in the signed token; the `_local/raw` route + emits `Content-Type` + an RFC 5987 `Content-Disposition` (ASCII fallback + + `filename*=UTF-8''…` for non-ASCII names). The S3 adapter bakes the same into + the signed URL via `ResponseContentType` / `ResponseContentDisposition`. + - Default disposition is `inline`, so previewable types (PDF, images) still open + in the browser — now with the correct name when saved. + +- b098b0e: docs(ai): stop `tool.requiresConfirmation` promising a gate it does not provide (#3715) + + The flag is read by **no execution path** — not the LLM tool set, not + `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, not the MCP bridge. + Yet the authoring surface actively taught reliance on it: the Studio form + section was titled _"Access & safety"_ with helpText _"Ask user to approve + before executing (for destructive actions)"_, and the AI skill doc, MCP guide + and spec README all recommended it for destructive operations. + + The prune-or-wire decision is deliberately **deferred** (#3715 — the field's + shape is likely needed once side-effect tools exist, which `ToolCategory` + already anticipates with `action` / `integration` / `flow`). What changes now + is only the promise: + + - spec `.describe()` carries `[EXPERIMENTAL — not enforced]` + a pointer to the + real gate; + - the form section is renamed _"Declarative metadata (not enforced)"_ and both + its fields (this and the already-dead `permissions`) say so, with the enforced + alternative spelled out; + - `skills/objectstack-ai/SKILL.md`, `MCP_GUIDE.md` and `README.md` now point at + the action-level `ai.requiresConfirmation` + approval queue (and note that AI + metadata edits are already gated by draft/publish, ADR-0033). + + No behaviour change: nothing read the flag before and nothing reads it now. + +- 83c161f: feat(automation)!: a flow run with no trigger user may no longer touch data (#3760) + + An effective `runAs:'user'` run that resolves **no trigger user** used to execute + its data nodes **UNSCOPED** — it presented no principal, and the data security + middleware skips when there is no principal, so the run read and wrote every row. + `runAs:'user'` is an access-_narrowing_ declaration; failing to resolve it must + never resolve to a grant (ADR-0049). It now **refuses** the operation + (`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix. + + **This was never really about schedules.** The docs, the spec, the runtime + warning and the lint all described a schedule-shaped problem, and the lint only + ever matched that shape. But the runtime predicate is "no user", and the + commonest way to have no user is a **record-change flow fired by a write that + carried none**: `isSystem` does _not_ suppress trigger dispatch — only + `skipTriggers` does, and exactly three first-party paths set it — so every + plugin/service system write, the approvals status mirror, and a `runAs:'system'` + flow's own data node dispatched record-change flows with `userId: undefined`. + Ordinary users reach those writes routinely (submitting for approval mirrors a + status onto the target record), so the fail-open was reachable by unprivileged + input and was the common case, not the rare one. + + Deliberately **not** implemented as "inherit the triggering write's posture and + run as `isSystem`". That reads like a relabel but is a privilege escalation: the + security middleware's `isSystem` short-circuit fires _before_ its + package-managed-row, system-row, audience-anchor and delegated-admin gates, all + of which a principal-less context still has to clear. Such a run cannot write + `sys_user_position` today; as `isSystem` it could. "Unscoped" was never + equivalent to "system". + + **Breaking — how to migrate.** A flow that reacts to system writes and needs to + act beyond one user's grants declares `runAs: 'system'`, making the elevation + explicit and audit-attributable. Otherwise ensure the trigger supplies a user. + Flows that touch no data are unaffected (`runAs` is moot), and the failure is + isolated: the trigger already swallows flow errors, so the originating write + still succeeds. The engine warns at run _setup_, before any node executes. + + **#3712's user-less provenance path is subsumed, not broken.** That fix let a + run with no trigger user write its own approval-locked record by carrying a + provenance-only ObjectQL context (the run id, nothing else). Such a run can no + longer perform a data operation at all — presenting no principal is exactly what + made the write unscoped — so it is refused before the lock is consulted. The + capability survives via the explicit route: a schedule that must write records + declares `runAs:'system'`, which the lock hook exempts on its own `isSystem` + branch. The `flowRunId` exemption itself stays live and load-bearing for what + #3703 built it for — a `runAs:'user'` run that _does_ have a user — where the + exemption is still provenance rather than privilege. + + Also in this change: + + - **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the + build.** It read as a gate and behaved as a comment — `os compile` documented + that the flow lint "NEVER fails the build" — which is close to no net at all + for the audience it protects, very often an AI generating flows in bulk. It now + also covers the other provably user-less triggers (`time_relative`, `api`), per + ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at + authoring time — that is exactly why the runtime refusal exists. + - **Three seed writes stopped firing automation.** The seed loader's pass-2 + deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks + inlined a bare `{ isSystem: true }` instead of the shared seed options, so they + seeded with record-change automation live — the self-trigger vector + `skipTriggers` exists to prevent, on the writes that skipped it. + - **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot + trigger a schedule, so there is no untrusted-input path") is falsified, and its + rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows + relied on the default") expired when those flows were fixed to declare + `runAs:'system'`. Refusal is an interim posture, forward-compatible with the + ADR's `automation` principal: when that lands, the refusal point becomes the + place that resolves it. + +- 69f1dfd: fix(webhooks): materialize stack-declared webhooks into the dispatcher (#3461) + + A webhook authored declaratively — `defineStack({ webhooks })` / `defineWebhook()`, + validated against the spec `WebhookSchema` — was a **silent no-op**. The runtime + dispatcher (`AutoEnqueuer`) fans out off `sys_webhook` DATA rows (`object_name` / + `active`), which until now were only ever written by hand through the object's + CRUD UI. Nothing turned a declared webhook (`object` / `isActive`) into a + dispatchable row, so authoring `webhooks:` on a stack produced `webhook` metadata + that never fired (ADR-0078). The showcase app itself shipped a `webhooks:` entry + that did nothing. + + `@objectstack/plugin-webhooks` now bridges the two on boot: + + - **`bootstrapDeclaredWebhooks`** reads declared `webhook` metadata from the + ObjectQL registry (where the manifest decomposition already parks + `stack.webhooks`), validates each through `WebhookSchema.parse()` — the spec + schema finally has a real consumer — and materializes it into a `sys_webhook` + row, mapping `object → object_name`, `isActive → active`, and stashing the full + envelope (headers / secret / retry / timeout) in `definition_json`. The + auto-enqueuer's first cache refresh then picks the row up and dispatches it. + - **Seed-not-clobber provenance** (mirrors `sys_sharing_rule`, #2909): `sys_webhook` + gains `managed_by` / `customized` columns. Declared webhooks re-seed every boot + as `managed_by: 'package'`, but a row an admin created (`managed_by: 'admin'`) or + edited in Setup (`customized: true`, stamped by a `beforeUpdate` hook) is never + overwritten — a deactivated noisy webhook survives redeploys. + + Connector-declared `webhooks` remain not-yet-enforced (that is a separate seam, + #3197). Registering `webhook` as a first-class metadata type + enrolling it in the + liveness `GOVERNED` set is a tracked follow-up. + + Migration: none required. Existing hand-authored `sys_webhook` rows default to + `managed_by: 'admin'` and are never touched by the seeder. Anyone who authored + `webhooks:` on a stack expecting it to fire will find it now does — review those + declarations (especially `url` / `isActive`) before upgrading. + ## 16.1.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 720c847e54..0858946333 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "16.1.0", + "version": "17.0.0-rc.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/spec/src/kernel/protocol-version.ts b/packages/spec/src/kernel/protocol-version.ts index e986a83160..e35b717486 100644 --- a/packages/spec/src/kernel/protocol-version.ts +++ b/packages/spec/src/kernel/protocol-version.ts @@ -15,7 +15,7 @@ * Kept in lockstep with the package's own major; `protocol-version.test.ts` * asserts it against `package.json` so the two cannot drift. */ -export const PROTOCOL_VERSION = '16.0.0'; +export const PROTOCOL_VERSION = '17.0.0'; /** The protocol major as an integer — the value the handshake compares. */ export const PROTOCOL_MAJOR: number = Number.parseInt(PROTOCOL_VERSION.split('.')[0]!, 10); diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 43dde3534b..0d25c357fa 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/trigger-api +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 1492f1d30a..705c5fd3dc 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "16.1.0", + "version": "17.0.0-rc.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 86b564cf60..80676fff45 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,248 @@ # @objectstack/plugin-trigger-record-change +## 17.0.0-rc.0 + +### Minor Changes + +- 6f55c63: feat(trigger-record-change): `record-after-write` fires one flow on create OR update (#3427) + + A `record_change` flow's `start` node bound to exactly one lifecycle event via + `triggerType`, so a rule meant to run on both insert and update ("recompute the + SLA whenever a case is created or its priority changes") forced authors to + duplicate the whole flow — two near-identical definitions that drift. + + Adds `record-after-write` and `record-before-write` as the **create-OR-update + union** trigger tokens. One `start` node binds both lifecycle hooks + (`afterInsert` + `afterUpdate`) under the same flow; exactly one fires per + mutation (a write is an insert _xor_ an update), so it is not a double run. + `delete` is deliberately excluded — a write persists field data, a delete + removes the row. To branch on which event fired inside the flow, test + `previous` (empty on create, populated on update). + + - `triggerTypeToHookEvents(triggerType)` (new, plural) is the canonical mapper: + it returns the list of hook events a token binds, expanding `write` to both. + `triggerTypeToHookEvent` (singular) is kept for back-compat and now returns + `null` for the multi-event `write` tokens rather than silently dropping a + binding. + - The engine already forwards any `record-*` token through to this trigger, so + no engine, lint, or spec change is needed — the trigger owns the vocabulary. + + Documented under Automation › Flows (Create-or-update flow) and the trigger's + README. + +### Patch Changes + +- 9dcc0ae: fix(automation): array-form flow `triggerType` fails loudly instead of silently never firing (#3481) + + An array `triggerType` on a flow start node — the shape an author (or an AI + authoring pass) naturally reaches for to fire on more than one event, e.g. + + ```ts + config: { objectName: 'app_task', triggerType: ['record-after-create', 'record-after-delete'] } + ``` + + was accepted everywhere and armed nowhere. Multi-event unions are deliberately + unsupported (only the single tokens plus the `record-after-write` create-OR-update + union exist — see #3457), but nothing said so: `defineFlow` passed the array + (start-node `config` is an open record), the engine's `typeof === 'string'` check + folded it to no trigger and misclassified the flow as **manual**, so it never + entered the trigger-binding audit, and the flow-trigger-readiness lint used the + same `typeof` narrowing and produced no finding. The flow bound to nothing and + never fired, with zero output at any layer — the same silent-never-fire class as + #3427 / #3472, and the last authoring shape still slipping past every guard. + + This is a **defensive** fix — arrays remain unsupported; they now fail loudly: + + - **lint** (`validate-flow-trigger-readiness`): an array `triggerType` containing + any `record-*` element now yields a `flow-trigger-unknown-event` warning at + `os validate` time, steering to `record-after-write` (for created-or-updated) or + one flow per event. + - **engine** (`resolveTriggerBinding`): such an array is routed to the + `record_change` trigger — exactly as an unmappable single token is — instead of + being folded to a manual flow, so it reaches the trigger's bind-time rejection. + - **trigger** (`record-change`): the bind-time rejection detects the array shape + and emits a targeted warning (naming the flow, pointing at `record-after-write` + and #3457) rather than the generic unknown-token line. + +- 169b58a: fix(#3426): build-time warning for unresolvable flow template paths + guard the formula re-read + + Two follow-ups to #3426 (the formula/lookup `{record.}` template gap that #3445 began closing). + + **Build-time signal (the issue's fallback ask).** `os validate` now flags a + record-change flow node whose `{record.}` template cannot resolve — + turning the previous SILENT blank into an advisory warning. Two cases, via the + new `@objectstack/lint` rule `validateFlowTemplatePaths`: + + - `flow-template-unknown-field` — `{record.}` where `` is neither a + declared field nor a system column (a typo like `{record.full_naem}`). + - `flow-template-lookup-traversal` — `{record..}`, a cross-object + hop the seeded record carries only as a scalar id (still unsupported; tracked + on #3426). + + Deliberately quiet: formula fields, bare lookup ids, numeric indexes into + `multiple` lookups (#1872), `json` sub-paths, and system columns are NOT flagged, + and flows bound to an object this stack does not define are skipped (no schema to + compare against). + + **Hydration re-read guards.** The `trigger-record-change` computed-field re-read + (#3445) is now (a) skipped when the object declares no `formula` field — the only + thing it adds — via the engine's optional `getObjectConfig`, and (b) memoized per + write on the shared HookContext, so N flows on one written record share ONE + re-read instead of N. Any uncertainty falls back to the prior unconditional + re-read (correctness over the optimization). + +- 1dc94f0: fix(trigger-record-change): hydrate read-time formula fields onto the seeded flow record (#3426) + + A `formula` field is a read-time virtual — the engine evaluates it post-fetch on + `find`/`findOne`, never on the write path — so it was absent from the raw + after-create/after-update row a record-change flow is seeded with. A notify + node template like `{record.full_name}` (or a start condition on the same field) + therefore resolved to an empty string, silently emitting notifications such as + `"New lead to assign: "` with the name missing. + + The record-change trigger now re-reads the just-written record through the data + engine, so the seeded `record` carries the same computed fields a data-API read + returns. The fix is at the trigger (the producer of the flow's `record`), so it + benefits the whole flow — start condition, every node, and notify `title`/`body` + templates — not just the notify node. + + Deliberately conservative: + + - Runs only for `afterInsert` / `afterUpdate` (the row exists in its post-write + state); `before*` and `afterDelete` keep the raw hook record untouched. + - Reads as an elevated system principal, so it can only ADD computed fields, + never let RLS/FLS on the re-read shrink the snapshot the flow already saw. + - Raw hook fields win on merge, preserving trigger-time scalar values and the + #1872 multi-lookup input overlay; the re-read only fills in keys the raw row + lacks (the formula virtuals). + - Any failure (no read surface, no id, a throw, an empty read) falls back to the + raw record — hydration never breaks the flow it feeds. + + Lookup **traversal** (`{record.account.name}`) is intentionally not hydrated: a + default data-API read does not expand relations either, and expanding would turn + `record.account` from its scalar FK id into an object, breaking templates and + conditions that use the bare id (e.g. #1872's `{record.target_channels.0}`). + That traversal remains tracked on #3426. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index ca0c8f460b..cbe317c688 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": "16.1.0", + "version": "17.0.0-rc.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 8017411e4d..9e01f41f32 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,128 @@ # @objectstack/plugin-trigger-schedule +## 17.0.0-rc.0 + +### Patch Changes + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 733f6e643a..122b7c2606 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "16.1.0", + "version": "17.0.0-rc.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 e0da4b5816..be725be16e 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,467 @@ # @objectstack/types +## 17.0.0-rc.0 + +### Minor Changes + +- 879ea13: ADR-0105 Phase 0 + Phase 1: group tenancy posture; organization scope as a + first-class authorization dimension. + + > This release carries BREAKING spec removals (see "Enforce-or-remove" below) + > but is recorded as `minor`: every publishable package is in the Changesets + > lockstep group, so one `major` would promote the whole monorepo. Breaking + > changes ship as `minor` during the launch window — the migration notes below + > are what reach consumers in `CHANGELOG.md`. + + ## Tenancy is now a spectrum (D1) + + `single | group | isolated`, resolved by the `tenancy` service and selected with + the new `OS_TENANCY_POSTURE` env var. Existing deployments are unchanged: + `OS_TENANCY_POSTURE` unset derives the posture from `OS_MULTI_ORG_ENABLED` + (`true` ⇒ `isolated`, else `single`). An unrecognized value throws at boot + rather than silently landing in a posture with no organization wall. + + - `single` — no wall (unchanged). + - `group` — **new.** Organizations are membership boundaries over one shared + dataset; Layer 0 becomes `organization_id IN accessible_org_ids` (union / MOAC + semantics). Enforced by the OPEN engine. + - `isolated` — today's `multi`, renamed. Behavior, enterprise `org-scoping` + probe and degraded-boot handling all unchanged. + + ## Organization scope is a first-class context field (D2) + + `ExecutionContext.accessible_org_ids` — every organization the caller holds a + currently-valid membership in (ADR-0091 validity windows) — is resolved once by + `resolveAuthzContext` and carried by every transport. The `group` wall reads it + directly; RLS policies may reference it as + `organization_id IN (current_user.accessible_org_ids)`. An empty or absent set + fails the wall closed. + + Only the Layer 0 PREDICATE widens. Composition is untouched: the wall is still + computed independently of the RLS compiler, AND-composed outermost, and + crossable only by a true `PLATFORM_ADMIN` on a posture-permitting object — so + ADR-0095's W1/W2 invariants hold in every posture. + + ## Two P0 correctness fixes (D3, D4) — behavior changes + + **D3 — app-authored org-scoped RLS policies are no longer silently dropped** + (finding F1, framework#3539). `collectRLSPolicies` used to strip any policy whose + `using` contained the substring `current_user.organization_id` when isolation was + inactive, which swallowed app-authored policies as well as the platform's own. + Stripping is now decided by PROVENANCE (identity against the shipped + declaration). **Upgrade impact:** in a deployment with no organization wall, an + app-authored policy referencing the active organization is now RETAINED and + fails closed (zero rows) with a one-time warning, where it previously vanished + and the object read unscoped. `getReadFilter` shared the defect, so analytics and + raw-SQL consumers were affected too. If a policy was only ever meant for + multi-org, delete it or install `@objectstack/organizations`. + + **D4 — `viewAllRecords`/`modifyAllRecords` never cross an organization + boundary** (finding F2, framework#3540). Under a wall-less posture nothing + bounded the wildcard superuser bits `organization_admin` carries, so a + deployment that accumulated organizations (personal orgs on signup) made every + owner/admin an environment-wide superuser. `auto-org-admin-grant` now grants a + de-VAMA'd `organization_admin_no_bypass` variant when no wall is enforced, and + revokes the superseded variant whenever the posture changes. **Upgrade impact:** + in `single` posture an org owner/admin keeps full CRUD but loses the blanket + ownership/sharing/RLS bypass. Deliberate deployment-wide visibility remains + available through `admin_full_access` or an explicitly authored permission set — + it just stops being a side effect of a better-auth membership role. + + ## Engine-owned organization stamping (D5) + + Under any wall-enforcing posture the engine stamps `organization_id` from the + caller's active organization on an insert that omits it, and validates every + supplied value against the wall. Idempotent with the enterprise auto-stamp + (neither overwrites a supplied value). This also closes a real hole: the + pre-existing post-image check required a non-array payload, so a BULK insert + could carry a forged `organization_id` per row. One forged row now denies the + whole write. + + ## Group structure, extension fields and red-line lints (D6, D7) + + - `sys_organization` gains `parent_organization_id` and `sort_order` — a + **reporting dimension only**. + - New lint `validateOrgAxisRedLines` (`org-axis-permission-inheritance`, + `org-axis-cross-org-bu-grant`), wired into `os lint` / `os compile` / + `os validate`: an RLS policy or sharing rule that walks the org tree is an + error, as is a business-unit grant on a platform-global object. + - Extension fields on better-auth-managed objects ride the existing ADR-0092 + whitelist. A new guard derives better-auth's real field surface from + `getAuthTables()` at the pinned version and fails the build on any name + collision, so a library upgrade cannot silently take ownership of a column. + + ## Enforce-or-remove (D11) — BREAKING + + Both removals are of surface that had **zero runtime consumers**, so no + behavior changes; authoring them is now a no-op instead of a lint warning. + + - **`PermissionSet.contextVariables` — REMOVED.** The RLS compiler never read + it. FROM → TO: a set a policy needs as `field IN (current_user.)` is now + supplied by a registered membership resolver (below); a constant belongs in + the policy itself as a literal (`status = 'published'`). + - **`Territory` / `TerritoryModel` / `TerritoryType` (`security/territory.zod.ts`) + — REMOVED.** No runtime object, stack field or resolver existed. FROM → TO: + matrix requirements are served by multi-position × business-unit anchoring; a + generalized dimension-security module will arrive with its own ADR. + - **`ExecutionContext.rlsMembership` — PRODUCTIZED.** The bag the compiler has + merged since ADR-0056 finally has a producer: register an + `IRlsMembershipResolver` (`@objectstack/spec/contracts`) under the + `rls-membership-resolver` service, declaring the keys it owns. Fail-closed by + construction — an unresolved key makes its policies drop out. Kernel-owned + keys (`accessible_org_ids`, `org_user_ids`, …) are reserved and cannot be + overwritten from this seam. + + ## Edition boundary (D12) + + The `group` posture's enforcement primitives ship OPEN — the union wall, + `accessible_org_ids` resolution, D5 stamping/validation, the D3/D4 correctness + fixes and the D6 lints — because the correctness of a wall is never a paid + feature (cloud ADR-0016 铁律「强制免费、治理收费」). `isolated` keeps its existing + enterprise `org-scoping` probe, so the current commercial boundary for + legal-entity isolation is unchanged by this release. + +- 840ee4b: fix(analytics,runtime,types): gate cube auto-inference on object existence; stop the dispatcher boundary returning raw SQL (#3867) + + Two independent defects on the `/analytics` surface, found while verifying #3770 + against a real server. On an authenticated CRM dev server, before this change: + + ``` + POST /api/v1/analytics/query {"cube":"sqlite_master","measures":["count"],"dimensions":["type"]} + → 200 {"rows":[{"type":"index","count":262},{"type":"table","count":71},{"type":"view","count":1}], + "sql":"SELECT type AS \"type\", COUNT(*) AS \"count\" FROM \"sqlite_master\" GROUP BY type"} + ``` + + That is SQLite's internal schema table — never a registered object — read + successfully through the analytics endpoint. Not merely "the name reaches the + driver and errors": **any table the connection can see was readable.** + + **① The cube name reached the driver as a table name.** `AnalyticsService.ensureCube` + auto-infers a minimal Cube when none is registered, with `cube.sql = `. That is the intended "metric over an object" path — an `object-metric` KPI + widget queries `crm_account` with no authored Cube — but it accepted _any_ string, + so the endpoint could aggregate over an arbitrary physical table. The + analytics-side twin of the data-path gap #3770 closed, and it was not covered by + that fix: #3770 gated the protocol's `analyticsQuery`, which is the _degraded + fallback_; a deployment with `@objectstack/service-analytics` installed runs the + real engine instead (`ctx.replaceService`). + + Inference is now gated on the same schema registry the data path consults, via a + new optional `AnalyticsServiceConfig.isRegisteredObject` that `plugin.ts` wires + from the `data` engine's `getObject`. Three-way rule: a registered Cube runs + untouched (its `sql` is whatever it declares); an unregistered name that IS an + object still auto-infers exactly as before; neither → `CUBE_NOT_FOUND` / 404 + raised before any SQL exists, naming both ways to make the request valid. With no + probe configured the gate stands down and warns once — the same tiering #3770 + took for a missing registry. `generateSql` (`/analytics/sql`) is gated too. + + **② The dispatcher boundary returned `err.message` verbatim.** `errorResponseBase` + is the single error exit for _every_ route the dispatcher plugin mounts — + `/analytics`, `/packages`, `/i18n`, `/storage`, `/automation`, `/auth`, + `/notifications`, `/mcp`. `@objectstack/rest` has guarded its data routes against + driver dumps forever (`mapDataError`); this boundary guarded nothing, so any + driver error on any of those routes shipped its SQL to the client. Unlike ①, this + half is unconditional — it does not depend on the cube being invalid. + + The leak heuristic moved out of `rest-server.ts` into `@objectstack/types` as + `looksLikeInternalErrorLeak` (both packages already depend on it) and is now + applied at both boundaries — one predicate, one place to widen when a new + dialect's phrasing shows up. `mapDataError`'s behaviour is unchanged. At the + dispatcher it applies **only to 5xx**: a 4xx message is a deliberate + business/validation answer and must reach the caller intact. Sanitising costs no + diagnostics — the untouched error still reaches `errorReporter` through the + existing `__obsRecordedError` side-channel. + + **Also fixed in the same function:** `errorResponseBase` read only + `err.statusCode`, while domain errors across this codebase carry `status` (and + `HttpDispatcher.errorFromThrown` already reads `status` first). Every deliberate + 4xx thrown through a dispatcher route — including #3770's `OBJECT_NOT_FOUND` on + the analytics fallback path — was rendered as a **500**. It now reads `status` + then `statusCode`. + + **Behaviour change.** `/analytics/query` and `/analytics/sql` return 404 + `CUBE_NOT_FOUND` for a cube that is neither registered nor a registered object; + previously the name was passed to the driver. Dashboards and KPI widgets pointed + at real objects or authored cubes are unaffected. A 5xx on a dispatcher route + whose message looks like a driver dump now reads `Internal server error` — check + server logs or your error reporter for the original. + +- 030125b: feat(objectql)!: `init()` refuses to boot when a data driver fails to connect (#3741) + + `ObjectQLEngine.init()` wrapped every driver's `connect()` in a try/catch, logged + one error line, and carried on. A server whose database was unreachable therefore + "started successfully" — health endpoints could even stay green — and then failed + every request with an error that reads nothing like _the database is down_. The + warning it printed (`Operations may recover via lazy reconnection or fail at query +time`) was half fiction: grep the repo and no reconnection exists in `driver-sql` + or `driver-mongodb`, so only the "fail at query time" half was ever real. The + caller made it worse — `ObjectQLPlugin.start()` runs `syncRegisteredSchemas()` + immediately after `init()`, issuing DDL against a driver that isn't there. + + The structural half of the bug was worse than the operational one: the catch + removed a driver's ability to **refuse startup at all**. Any fatal startup check — + licence, server version, incompatible configuration, missing capability, not just + an unreachable socket — is expressed by throwing from `connect()`, and every one + of them was silently downgraded to a runtime error. That is why driver-mongodb's + multi-tenancy guard (#3724 / #3734) had to be hoisted into its constructor. + + - `init()` now **throws** `DriverConnectError` (`code: 'ERR_DRIVER_CONNECT'`) + when any boot-registered driver's `connect()` rejects, aborting kernel + bootstrap. It still attempts every driver first, so one failed boot names all + of them. The message is self-contained — each failed driver and its cause — + because the CLI prints `error.message` alone; the first cause is also attached + as `error.cause`. Exported from both `@objectstack/objectql` and + `@objectstack/objectql/core`. + - `connect()` is now a supported place for a driver to veto boot. Startup + validation that needs a live connection (server version, capability probes) + no longer has to be forced into a constructor. + - The misleading "lazy reconnection" warning is gone. + - New escape hatch `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` + (`resolveAllowDriverConnectFailure()` in `@objectstack/types`) restores the old + lenient boot, but loudly: a `DEGRADED BOOT` banner names the failed drivers and + states that they are never retried or reconnected and that every query and + schema sync routed to them will fail for the process lifetime. The banner goes + to stderr as well as the logger, because `os serve` swallows all of stdout + during boot and `Logger` routes `warn` there — logger-only, the one message + that matters would be invisible in exactly the deployment the flag is for. + Defaults off. + + **Migration.** No code or config change is needed for a correctly configured + deployment — a driver that connected before still connects. A deployment that was + _silently_ booting without its database now fails the boot instead, with the + driver name and cause in the error; fix the datasource configuration (typically + `OS_DATABASE_URL`, credentials, or network reachability). To keep booting without + it — deliberately, and knowing every request that touches it will fail — set + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +### Patch Changes + +- 87aca93: fix(datasource)!: a declared datasource that objects bind to must connect, or the boot fails (#3758) + + `DatasourceConnectionService.handleFailure()` fail-fasted only for an `external` + datasource with `validation.onMismatch: 'fail'`. Everything else degraded to one + `warn` line — including the case the D2 auto-connect gate itself flags as having + **no fallback path**: a datasource that objects bind to explicitly via + `object.datasource`. Those objects never fall through to the `default` driver; + `engine.getDriver` throws `Datasource 'x' is not registered` for them. + + So an app declaring `datasource: 'analytics'` with 20 objects bound to it, booted + against a wrong `ANALYTICS_URL`, started clean and exited zero — and then failed + every read and write of those 20 objects with an error that reads nothing like + _the analytics database is unreachable_. The rest of the app worked, which made it + **harder** to locate than a total outage: it looks like "some pages are broken", + not like a misconfigured datasource. This is the same decision #3741/#3751 fixed + one layer up in `ObjectQLEngine.init()`; the boundary here was still drawn in the + old place. + + - **Fail-fast is now keyed on "no fallback path", not on `onMismatch` alone.** At + the `declared-auto` (boot) trigger, a connect failure aborts the boot when the + datasource is `external` + `onMismatch: 'fail'` **or** when ≥1 object binds to + it explicitly. `autoConnect: true` with nothing bound stays lenient — that is + "connect it if you can", and nothing declares a dependency on it. The + runtime-admin create/update and boot-rehydration triggers are unchanged and + still always degrade: a UI action must never brick a running server. + - **Every failure mode counts**, not just an unreachable socket: an unresolvable + `external.credentialsRef` (D3) and an unsupported `driver` leave the bound + objects exactly as dead, so they take the same verdict. + - **The error names the bound objects** (up to 10, then `+N more`) alongside the + underlying cause, so the message points at the real problem instead of just the + datasource name. The service already receives the list for post-connect + `syncObjectSchema`. + - **`connectDeclared()` attempts every gated datasource before throwing**, and + aggregates, so one failed boot reports all the misconfigured ones rather than + one per restart — the same shape as `ObjectQLEngine.init()`'s + `DriverConnectError`. + - **The escape hatch is shared with the engine guard**: + `OS_ALLOW_DRIVER_CONNECT_FAILURE=1` now also covers this path (and covers + `onMismatch: 'fail'`, which previously had no opt-out). The operator intent is + identical — "I know the database is unreachable, boot anyway" — and two flags + would only guarantee one of them gets missed. When set, boot continues and a + `DEGRADED BOOT` banner goes to stderr as well as the logger, because `os serve` + swallows stdout during boot. `emitDegradedBootBanner` moved to + `@objectstack/types` so both call sites share one implementation; + `@objectstack/objectql` re-exports it unchanged. + + ADR-0062 D5 is amended with the new criterion and the shared flag. + + **Migration.** No change for a correctly configured deployment — a datasource that + connected before still connects. A deployment that was _silently_ booting with a + dead, explicitly-bound datasource now fails the boot instead, naming the + datasource, the cause, and the objects that depend on it; fix the datasource + configuration. To keep booting without it — deliberately, knowing every request + touching those objects will fail — set `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`. + +- 32d3800: fix(driver-sql): bound a connection attempt at 10s, and correct the "no reconnection" claim (#3769, #3759) + + Two related corrections, both from measuring what #3741/#3751/#3765 had only asserted. + + **The claim was wrong.** #3751 and #3765 shipped several statements that drivers + never reconnect — "there is no lazy reconnection", "NOT retried and NOT + reconnected", "stays disconnected for the process lifetime". Measured, both + drivers recover on their own: + + - driver-mongodb: killing a real `mongod` and restarting it on the same port, + the _same_ driver instance served the next write successfully (13ms), with no + reconnect call from us — the official driver's topology monitor handles it. + - driver-sql: a knex/pg pool is not poisoned by an outage. Its error tracks live + server state (`ECONNREFUSED` while down → a handshake error once a listener is + back → `ECONNREFUSED` again), i.e. every acquire opens a fresh connection. + `storage-driver.ts` also configures `pool.min: 0`, so no stale idle + connections are held. + + The original reasoning grepped this repo for `reconnect`, found nothing, and + concluded recovery does not happen — but the recovery lives in the client + libraries, not in our code. The claims are now corrected in `DriverConnectError`, + the `DEGRADED BOOT` banner, `resolveAllowDriverConnectFailure`'s docs, and the + drivers / self-hosting pages. + + **Fail-fast at boot is unchanged and still correct** — the reason is just + different. It is not that the connection can never return; it is that the _boot + sequence_ never re-runs. A driver that missed `init()` also missed + `syncRegisteredSchemas()`, so its tables can simply not exist even after the + database comes back. The banner now says that. + + **The real defect underneath.** `SqlDriver` passed its config to knex untouched, + so a database endpoint that accepts TCP but never completes the handshake — an + overloaded instance, a half-open firewall, a load balancer mid-failover — made + every query wait out tarn's 30s default, then fail with `Timeout acquiring a +connection. The pool is probably full`, pointing an operator at pool sizing + instead of the network. With a small `pool.max` a few such queries saturate the + pool and everything else queues. + + `SqlDriver` now defaults `pool.createTimeoutMillis` to **10s**, matching + driver-mongodb's existing `connectTimeoutMS ?? 10_000` so both drivers give up on + an unreachable server at the same point. A host that sets its own + `createTimeoutMillis` is left alone. + + **Migration.** None for a healthy datasource. A deployment that deliberately + relies on connection establishment taking longer than 10s (a slow cross-region + replica) should set `pool.createTimeoutMillis` explicitly on its `SqlDriver` + config. + + Not fixed here, tracked in #3769: knex still reports the bounded wait as "the + pool is probably full". An accurate message needs a dialect-specific connect + timeout (pg's `connectionTimeoutMillis`), which changes the shape of `connection` + and would regress the startup banner's URL display. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [587fc91] +- Updated dependencies [1986594] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [db02d47] +- Updated dependencies [0bfdf46] +- Updated dependencies [376a061] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [9ea2bc5] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [2fa4ca1] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [de9af8a] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [189854c] +- Updated dependencies [0e3a226] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [41642b0] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [dac6a08] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [0045682] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [67452d1] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [5ac93d4] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [7687f7b] +- Updated dependencies [1659072] +- Updated dependencies [abceb0d] +- Updated dependencies [0c302a7] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [cde1975] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/types/package.json b/packages/types/package.json index 0c2c46704f..eaf148b248 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "16.1.0", + "version": "17.0.0-rc.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 46121f01df..19a669fd52 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,479 @@ # @objectstack/verify +## 17.0.0-rc.0 + +### Minor Changes + +- 587fc91: feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt + + The analytics→engine bridge now forwards the request's `ExecutionContext` to + `engine.aggregate`, so the engine's own middleware chain scopes analytics reads + independently of the analytics layer's `getReadScope`. + + **Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing + forced the bridge to pass it — and it did not. An authenticated aggregate + reached the engine with no principal, plugin-security's principal-less fall-open + skipped its RLS injection, and the only thing left scoping the query was the + strategy remembering to call `getReadScope`. #3597 was a strategy that did not, + and both belts were off at once. + + `getReadScope` stays: the two resolve scope through different paths (engine + middleware vs `security.getReadFilter`), and a deployment without + plugin-security has only the analytics layer. This is depth, not a replacement. + + - `StrategyContext` gains `context?: ExecutionContext`, bound per call by + `AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`. + - `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` / + `AnalyticsService` `executeAggregate` config options gain `context?: +ExecutionContext`. **Custom bridges should forward it** to their engine; the + built-in auto-bridge does. Purely additive — an existing bridge that ignores + it keeps working exactly as before. + - `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain + an optional trailing `context`, beside the `scope` / `resolveScope` that + #3639 added — the same two-belt split as the aggregate path. + - `BootOptions.analytics` (`@objectstack/verify`) overrides the + AnalyticsServicePlugin instance, so a gate can boot with the analytics belt + off and assert the engine-side belt alone still scopes. + + **Also fixed on the same seam:** + + - `fetchRecordLabels` — the dimension display-label lookup — is row-granular + (one row per record, real display names). #3639 gave it the analytics-layer + belt (the referenced object's own read scope); it now also carries the + context, so the engine scopes the same read independently. + - `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the + `/analytics/sql` preview read as an unscoped table scan while the real + aggregate was scoped. It now renders the caller's filters and the read scope. + The preview never executed, so this was misleading output rather than a leak. + +- 680e8e8: feat(verify): `checkDateBucketParity` — pin the seam between pushed-down and in-memory date bucketing + + A driver that advertises `supports.queryDateGranularity[g]` is telling + `engine.aggregate` it may push `dateGranularity: g` down as SQL instead of + fetching rows and bucketing them in JS. The two are then not two features but + one feature with two implementations, and the engine picks between them per + query — a granularity the driver advertises goes down as SQL, one it does not + goes to `applyInMemoryAggregation`, and a non-UTC timezone forces the in-memory + path regardless. A dashboard can cross that seam mid-drill-down. + + Nothing checked that they agree. That is how #3773 shipped: SQLite stores a + `Field.datetime` as INTEGER epoch milliseconds, `strftime` read the bare integer + as a Julian day number, and every row bucketed as NULL — a trend chart collapsed + into a single bar while every gate stayed green. The driver's own bucket suites + build their fixtures with `knex.schema.createTable` + `t.string(...)`, which is + ISO TEXT — the half `strftime` parses natively — and the engine never + second-guesses a granularity a driver claims to support. + + `checkDateBucketParity(driver)` rounds a fixture through the driver and, for + every granularity it advertises, compares its pushed-down result against the + REAL `applyInMemoryAggregation` over the driver's own `find()` rows. Both + temporal storage forms are probed under one object (`Field.datetime` and + `Field.date` naming the same calendar days), so a storage-form leak shows up as + the two columns bucketing differently even when each is internally consistent. + A granularity the driver does not advertise is skipped, never faulted. + + It follows `checkReadCoercion`: human-readable problems (empty = conformant), no + test-runner dependency, driver taken structurally — so an out-of-tree driver + runs the identical contract against itself. That matters most for cloud's + `driver-turso`, which is remote SQLite with exactly the epoch storage that broke + here. + + Wired up in `packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts` + against driver-sql and driver-sqlite-wasm, with negative controls that pin what + the checker can detect. Verified against the real regression, not just fakes: + reverting the #3773 fix turns the gate red on both drivers with a diagnostic + naming the collapsed bucket. + + The three test files that hand-copy `bucketDateValue` (driver-sql cannot depend + on objectql) now say what their `⚠️ Keep in sync` comments cannot enforce — a + copy that stops tracking its original leaves the copy and the SQL agreeing with + each other while both are wrong — and point at the executable check. The same + pointer is on `bucketDateValue` itself, which is where an edit would start the + drift. + +- a227ed7: fix(objectql)!: one key for the empty group bucket — real `null`, on both aggregation paths (#3839) + + A grouped row whose dimension value is empty now carries `null` for that + dimension no matter which way the aggregate ran. Downstream code can test the + empty bucket with a plain `value == null` again: charts render their own empty + label, drill-through on that bucket builds `field = null` and returns the rows + it should, and a dashboard no longer changes shape when the driver, the + granularity or the reference timezone changes. + + ### What was wrong + + `engine.aggregate` has two implementations of one feature. It pushes the + aggregate down as SQL when the driver advertises every requested granularity and + the reference timezone is UTC; otherwise it fetches rows and buckets them in JS. + The two disagreed about how to spell "empty": + + ``` + --- same dataset, same query, one row with a NULL value --- + pushed-down SQL : [{ "key": null, "type": "null", "total": 2 }, …] + in-memory : [{ "key": "(null)", "type": "string", "total": 2 }, …] + ``` + + The measures were always right — only the key's type and literal differed — + which is why this went unnoticed for so long: every total reconciled. But the + engine picks a path per query, so the same data produced a different bucket key + on SQLite-plus-UTC-plus-`month` than on `week` (which SQLite does not advertise), + a non-UTC timezone, or `driver-rest` / `driver-memory` / a remote Turso, all of + which bucket in memory unconditionally. + + It was never date-specific either. A plain `groupBy: ['stage']` over a NULL + column diverged the same way. + + Consumers are written against `null` — they check `== null` and supply their own + empty label ('—', '(empty)', a localized "Uncategorized"). The sentinel defeated + every one of them: it rendered a raw English debug string in the UI, and a drill + on the empty bucket compiled to `field = '(null)'` and matched nothing. + + The in-memory path's comment justified the string as staying "consistent with + the client `useReportData` hook". That hook was removed with ADR-0021, and the + literal never appeared in it. + + ### What changed + + - `applyInMemoryAggregation` and `bucketDateValue` (`@objectstack/objectql`) key + the empty bucket as `null`. `bucketDateValue` now returns `string | null`. A + null instant and an unparseable one still share one bucket, because SQL cannot + tell them apart either (`strftime('%Y-%m', 'not-a-date')` is NULL). + - The internal composite bucket id is JSON-encoded, so the empty bucket stays + distinct from a row whose value is the literal string `"null"`. + - `bucketKeyToCalendarRange` (`@objectstack/core`) accepts `string | null`. The + empty bucket has no calendar span, so a drill on it opens the unscoped + superset instead of an invented bound — unchanged behavior, honest signature. + - The driver output contract in `@objectstack/spec` now states the rule: a row + with no value keys as `null`, never a sentinel. Propagating NULL through the + bucket expression is the whole of it; a driver only breaks it by adding a + `COALESCE`. + + ### Gates + + `checkDateBucketParity` (`@objectstack/verify`) deliberately carried no null + instant, because the divergence would have failed it for a reason it was not + about. Its fixture now has one, so the convergence is held in place — including + for out-of-tree drivers that run the check against themselves. + + Two fixes were needed to make that fixture meaningful: + + - The check folded bucket labels through `String(value)`, which turns SQL NULL + into `'null'` — a label a TEXT column can genuinely hold. A driver spelling + "empty" as a string could compare equal to one returning real NULL. The empty + bucket is now keyed out of band. + - Label sets were compared with `JSON.stringify`, which is sensitive to key + insertion order. Row order is not part of this contract and the two paths + naturally differ (SQL sorts its groups; the in-memory path emits first-seen + order), so a driver with entirely correct buckets could be reported as + disagreeing — with an empty diff message, since nothing actually differed. + The comparison is now order-insensitive. + + A new dogfood check covers the non-date half against real drivers: same dataset, + plain and date-bucketed `groupBy`, both paths, one key. + +### Patch Changes + +- 0045682: feat(auth)!: membership grade is not a capability channel — the `sys_member.role` + vocabulary is closed (ADR-0108, #3723) + + `sys_member.role` answers "what is your standing in this organization". It does + not answer "what may you do" — that is what positions are for. One column was + answering both. + + `resolve-authz-context` projects EVERY value stored in `sys_member.role` into + `current_user.positions`, alongside the rows read from `sys_user_position`. So a + business role handed out through the membership role _was_ capability — granted + with none of the position system's controls: no `granted_by`, no ADR-0091 + validity window, no BU-subtree check, no `assignablePermissionSets` allowlist. + That is what ADR-0057 D4 ruled out ("feed the names to better-auth **only** so + invitations are accepted — **never as the authority for RBAC**"), what + ADR-0090 D3's word ban restates (distribution = `position`), and what + ADR-0095 D3 keeps out of the enforcement path. + + The vocabulary is therefore closed to the four framework-owned names: + `owner` / `admin` / `delegated_admin` / `member`. + + **BREAKING — `additionalOrgRoles` is removed** from `AuthManagerOptions` and + `AuthPluginOptions`, together with `plugin-auth/src/org-roles.ts` in full + (`collectStackOrgRoles`, `collectRegisteredOrgRoles`, + `normalizeAdditionalOrgRoles`, `membershipRoleOptions`, + `withMembershipRoleOptions`, `membershipRoleLabel`, `orgRoleNames`, + `MEMBERSHIP_ROLE_OBJECTS`, `OrgRoleDescriptor`, `OrgRoleInput`, + `OrgRoleLogger`) and the `kernel:ready` derivation hook that fed them. From + `@objectstack/spec`, `MEMBERSHIP_ROLE_NAME_PATTERN` and + `MEMBERSHIP_ROLE_NAME_MIN_LENGTH` are removed — they existed only to validate + app-supplied names. A TypeScript error is the intended failure: an option that + is silently ignored is `declared ≠ enforced` one more time. + + FROM → TO: + + ```diff + - new AuthPlugin({ additionalOrgRoles: ['sales_rep'] }) + + new AuthPlugin({ /* nothing — declare `sales_rep` as a position */ }) + + - POST /organization/invite-member { email, role: 'sales_rep' } + + POST /organization/invite-member { email, role: 'member', + + businessUnitId, positions: ['sales_rep'] } + ``` + + For an existing member, assign the position through `sys_user_position` (the + governed write path). Invitation placement (ADR-0105 D8) is the one-step + admission flow: issuance is authorized against the issuer's `adminScope` by + dry-running `DelegatedAdminGate`, and acceptance writes real + `sys_user_position` rows with a `granted_by` stamp. It reaches **further** than + what it replaces — a delegated admin may use it within their subtree, where the + membership-role route was open to org admins only (the invitation role cap holds + anyone below admin grade to plain `member`). + + An invitation naming an app role now fails at better-auth's door with + `ROLE_NOT_FOUND`, before any row is written. + + This reverses two changesets that were never consumed into a release + (`app-org-roles-storable`, `auth-org-roles-self-derived`), so no published + version ever offered the behaviour; both are removed rather than shipped and + retracted in the same changelog. A pre-existing deployment could only have + stored a custom value by direct DB write. + + Also derived rather than transcribed: `@objectstack/lint`'s `MEMBERSHIP_TIERS` + now reads `BUILTIN_MEMBERSHIP_ROLES` from `@objectstack/spec`. The hand-kept + copy carried `guest`, which the `sys_member.role` select has never offered — an + approver authored as `{ type: 'org_membership_level', value: 'guest' }` + resolved to nobody and the lint whose whole job is to catch that stayed silent. + +- e889386: `bootStack({ multiTenant: true })` now REQUESTS the `isolated` tenancy posture + for the boot (ADR-0105 D1), restoring the request on `stop()` and respecting an + explicit caller-provided `OS_TENANCY_POSTURE`. + + Since #3559 a walled posture is an explicit operator request resolved from env + when AuthPlugin registers the `tenancy` service — mounting the enterprise + organizations plugin only ENTITLES it. The harness's multi-tenant opt-in + predates that split and only mounted the plugin, so multi-org fixtures silently + booted `single`: no Layer 0 wall, D3 default-org write stamping, and every + cross-tenant proof asserting against the wrong posture (first surfaced by + cloud's security-enterprise multi-org integration test, which runs the licensed + path open-core CI cannot). + + The verify package also gains a `test` script so its suite actually runs under + `turbo run test`, including the new regression pin for this contract. + +- Updated dependencies [50616d9] +- Updated dependencies [08b5a3d] +- Updated dependencies [d99aeb3] +- Updated dependencies [4727eb8] +- Updated dependencies [f63cd09] +- Updated dependencies [6169615] +- Updated dependencies [fa3d0cf] +- Updated dependencies [af5a224] +- Updated dependencies [71f76e1] +- Updated dependencies [37b1346] +- Updated dependencies [a749273] +- Updated dependencies [99736a0] +- Updated dependencies [fe67e34] +- Updated dependencies [fdb4f50] +- Updated dependencies [1bd5652] +- Updated dependencies [735f850] +- Updated dependencies [14252d3] +- Updated dependencies [7fb436c] +- Updated dependencies [879ea13] +- Updated dependencies [201b31f] +- Updated dependencies [c7f4417] +- Updated dependencies [6fdc5c6] +- Updated dependencies [8b9d71e] +- Updated dependencies [33f5e23] +- Updated dependencies [259af21] +- Updated dependencies [6877e9a] +- Updated dependencies [0bab8bb] +- Updated dependencies [840ee4b] +- Updated dependencies [7101ca2] +- Updated dependencies [587fc91] +- Updated dependencies [415254c] +- Updated dependencies [1f8390b] +- Updated dependencies [3167e29] +- Updated dependencies [0a6fb1e] +- Updated dependencies [1986594] +- Updated dependencies [3c8cfd1] +- Updated dependencies [ad4af62] +- Updated dependencies [d44dbfa] +- Updated dependencies [f92096b] +- Updated dependencies [474fe39] +- Updated dependencies [0bc685a] +- Updated dependencies [b949059] +- Updated dependencies [be1c52c] +- Updated dependencies [c5ff96d] +- Updated dependencies [84e7be9] +- Updated dependencies [fb90784] +- Updated dependencies [a6c3f38] +- Updated dependencies [debc23a] +- Updated dependencies [0f8ad09] +- Updated dependencies [9dcc0ae] +- Updated dependencies [984396b] +- Updated dependencies [d0fea33] +- Updated dependencies [8f9689f] +- Updated dependencies [57a3bb3] +- Updated dependencies [9f060e5] +- Updated dependencies [bc17d39] +- Updated dependencies [db02d47] +- Updated dependencies [d3f2ff6] +- Updated dependencies [b7550d6] +- Updated dependencies [0164f40] +- Updated dependencies [e295ad1] +- Updated dependencies [1003125] +- Updated dependencies [6e62a93] +- Updated dependencies [ecda20c] +- Updated dependencies [6e62a93] +- Updated dependencies [fc968af] +- Updated dependencies [0bfdf46] +- Updated dependencies [3949a43] +- Updated dependencies [48c110e] +- Updated dependencies [87aca93] +- Updated dependencies [376a061] +- Updated dependencies [19e3e6e] +- Updated dependencies [7c7e246] +- Updated dependencies [f35cdc5] +- Updated dependencies [cbedd62] +- Updated dependencies [9ea2bc5] +- Updated dependencies [32d3800] +- Updated dependencies [c2d9098] +- Updated dependencies [a227ed7] +- Updated dependencies [9613396] +- Updated dependencies [e47b342] +- Updated dependencies [4ed7ed4] +- Updated dependencies [ce1f100] +- Updated dependencies [2fa4ca1] +- Updated dependencies [2f47489] +- Updated dependencies [7ef20d0] +- Updated dependencies [f5a2320] +- Updated dependencies [deb538f] +- Updated dependencies [5b89711] +- Updated dependencies [0c8a22f] +- Updated dependencies [763931e] +- Updated dependencies [c88eeda] +- Updated dependencies [de9af8a] +- Updated dependencies [5524f84] +- Updated dependencies [c4df271] +- Updated dependencies [a41ba5c] +- Updated dependencies [307e0fe] +- Updated dependencies [189854c] +- Updated dependencies [5d4de37] +- Updated dependencies [0e3a226] +- Updated dependencies [5602211] +- Updated dependencies [1d4756e] +- Updated dependencies [720c5ad] +- Updated dependencies [a8d1e24] +- Updated dependencies [d1cabaa] +- Updated dependencies [41642b0] +- Updated dependencies [aff9e56] +- Updated dependencies [4cca74c] +- Updated dependencies [88ef03e] +- Updated dependencies [9e2caf3] +- Updated dependencies [81ce41a] +- Updated dependencies [85e1e4e] +- Updated dependencies [65ac468] +- Updated dependencies [ef5e72d] +- Updated dependencies [dac6a08] +- Updated dependencies [313d7be] +- Updated dependencies [5faeac6] +- Updated dependencies [394b7a1] +- Updated dependencies [677b591] +- Updated dependencies [d77d1b7] +- Updated dependencies [5b79a34] +- Updated dependencies [c757854] +- Updated dependencies [e1fa8d5] +- Updated dependencies [402f534] +- Updated dependencies [0045682] +- Updated dependencies [7180ed5] +- Updated dependencies [083c414] +- Updated dependencies [2a5f04a] +- Updated dependencies [4f740b0] +- Updated dependencies [fc5f126] +- Updated dependencies [adabaa8] +- Updated dependencies [030125b] +- Updated dependencies [605c23f] +- Updated dependencies [67452d1] +- Updated dependencies [9bf4588] +- Updated dependencies [0fc6219] +- Updated dependencies [605e190] +- Updated dependencies [c6c59f1] +- Updated dependencies [b0e78a8] +- Updated dependencies [f31cc8d] +- Updated dependencies [f343dc4] +- Updated dependencies [8269e32] +- Updated dependencies [74f7339] +- Updated dependencies [a6c35a2] +- Updated dependencies [c2f1002] +- Updated dependencies [db48ad5] +- Updated dependencies [8e08bc3] +- Updated dependencies [16adb3c] +- Updated dependencies [f163028] +- Updated dependencies [f07808c] +- Updated dependencies [7ffc3d3] +- Updated dependencies [88346ba] +- Updated dependencies [4631592] +- Updated dependencies [32ff033] +- Updated dependencies [bbd902d] +- Updated dependencies [5ac93d4] +- Updated dependencies [3d5f726] +- Updated dependencies [70a1ce1] +- Updated dependencies [93f267f] +- Updated dependencies [0024abf] +- Updated dependencies [acbf364] +- Updated dependencies [48d5a1c] +- Updated dependencies [3216344] +- Updated dependencies [f5bfac8] +- Updated dependencies [6163393] +- Updated dependencies [688e9df] +- Updated dependencies [8f124a7] +- Updated dependencies [21ca1d5] +- Updated dependencies [03b11e8] +- Updated dependencies [8891f93] +- Updated dependencies [d729a31] +- Updated dependencies [cb8322e] +- Updated dependencies [aa8b847] +- Updated dependencies [7687f7b] +- Updated dependencies [d318b24] +- Updated dependencies [1659072] +- Updated dependencies [810a3a2] +- Updated dependencies [abceb0d] +- Updated dependencies [9981c1d] +- Updated dependencies [d60968c] +- Updated dependencies [0c302a7] +- Updated dependencies [5cfd4d5] +- Updated dependencies [6633337] +- Updated dependencies [f00d8d4] +- Updated dependencies [503be86] +- Updated dependencies [647ec8b] +- Updated dependencies [5f0852f] +- Updated dependencies [cde1975] +- Updated dependencies [20cb232] +- Updated dependencies [e231abb] +- Updated dependencies [0bc685a] +- Updated dependencies [b098b0e] +- Updated dependencies [4d00b13] +- Updated dependencies [a629074] +- Updated dependencies [57bab76] +- Updated dependencies [b90086a] +- Updated dependencies [b95577a] +- Updated dependencies [54f479a] +- Updated dependencies [83c161f] +- Updated dependencies [d8c4957] +- Updated dependencies [f24cb83] +- Updated dependencies [5dbbb92] +- Updated dependencies [69f1dfd] + - @objectstack/spec@17.0.0-rc.0 + - @objectstack/objectql@17.0.0-rc.0 + - @objectstack/rest@17.0.0-rc.0 + - @objectstack/runtime@17.0.0-rc.0 + - @objectstack/plugin-auth@17.0.0-rc.0 + - @objectstack/plugin-security@17.0.0-rc.0 + - @objectstack/core@17.0.0-rc.0 + - @objectstack/plugin-hono-server@17.0.0-rc.0 + - @objectstack/service-analytics@17.0.0-rc.0 + - @objectstack/service-automation@17.0.0-rc.0 + - @objectstack/service-datasource@17.0.0-rc.0 + - @objectstack/plugin-sharing@17.0.0-rc.0 + - @objectstack/service-settings@17.0.0-rc.0 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.0 + ## 16.1.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index c15843d390..104c8d3950 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "16.1.0", + "version": "17.0.0-rc.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module", diff --git a/packages/vscode-objectstack/CHANGELOG.md b/packages/vscode-objectstack/CHANGELOG.md index 8c7c079ee5..99f22fb719 100644 --- a/packages/vscode-objectstack/CHANGELOG.md +++ b/packages/vscode-objectstack/CHANGELOG.md @@ -1,5 +1,7 @@ # objectstack-vscode +## 17.0.0-rc.0 + ## 16.1.0 ## 16.0.0 diff --git a/packages/vscode-objectstack/package.json b/packages/vscode-objectstack/package.json index 00619f1c7e..6eb35662e7 100644 --- a/packages/vscode-objectstack/package.json +++ b/packages/vscode-objectstack/package.json @@ -2,7 +2,7 @@ "name": "objectstack-vscode", "displayName": "ObjectStack", "description": "ObjectStack Protocol — Autocomplete, validation, and inline diagnostics for .object.ts, .view.ts, and objectstack.config.ts files", - "version": "16.1.0", + "version": "17.0.0-rc.0", "publisher": "objectstack", "license": "Apache-2.0", "repository": {