From 8fb84e5c33fe5c5f4f936aec8f8e43bea998f6a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:37:44 +0000 Subject: [PATCH] chore: version packages (rc) --- .changeset/pre.json | 340 + examples/app-crm/CHANGELOG.md | 185 + examples/app-crm/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 276 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 227 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 156 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 317 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 173 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 173 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 173 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 1447 ++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 181 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 562 ++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 437 ++ packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 210 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 210 + .../connectors/connector-openapi/package.json | 12 +- .../connectors/connector-rest/CHANGELOG.md | 210 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 210 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 229 + packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 697 ++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 58 + packages/create-objectstack/package.json | 2 +- packages/formula/CHANGELOG.md | 302 + packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 1120 ++++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 213 + packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 221 + packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 38 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 1380 ++++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 574 ++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 1347 ++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 168 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 657 ++ packages/platform-objects/package.json | 2 +- packages/plugins/driver-memory/CHANGELOG.md | 584 ++ packages/plugins/driver-memory/package.json | 2 +- packages/plugins/driver-mongodb/CHANGELOG.md | 519 ++ packages/plugins/driver-mongodb/package.json | 2 +- packages/plugins/driver-sql/CHANGELOG.md | 841 +++ packages/plugins/driver-sql/package.json | 2 +- .../plugins/driver-sqlite-wasm/CHANGELOG.md | 364 + .../plugins/driver-sqlite-wasm/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 203 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 211 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 211 + .../plugins/knowledge-ragflow/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 439 ++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 230 + packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 496 ++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 568 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 245 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 743 +++ .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 164 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 256 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 583 ++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 633 ++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 379 ++ packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 193 + packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 138 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 20 + packages/qa/http-conformance/package.json | 4 +- packages/rest/CHANGELOG.md | 1398 ++++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 2873 ++++++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 69 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 637 ++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 1162 ++++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 205 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 207 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 239 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 744 +++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 369 ++ packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 211 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 175 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 292 + .../services/service-messaging/package.json | 4 +- .../services/service-package/CHANGELOG.md | 176 + .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 255 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 207 + .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 532 ++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 239 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 518 ++ .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 5887 +++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 263 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 175 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 175 + .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 435 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 349 + packages/verify/package.json | 2 +- packages/vscode-objectstack/CHANGELOG.md | 34 + packages/vscode-objectstack/package.json | 2 +- 153 files changed, 38420 insertions(+), 83 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index fd630823fb..f32e431261 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -82,13 +82,28 @@ }, "changesets": [ "action-alias-conflict-warning", + "action-body-execution-context", + "action-body-write-set-lint", + "action-crash-vs-rejection", "action-execute-target-precedence", + "action-governance-engine-owned", "action-param-inline-lookup-reference", "action-param-strict-unknown-keys", + "action-record-write-discarded-lint", + "action-record-writes-runtime-report", + "action-required-permissions-server-scope-docs", "action-undoable-liveness-corrected", + "actions-empty-object-segment-route", + "actions-failures-speak-http", + "actions-global-key-and-failure-status", + "adapter-hono-auth-wildcard-yields", "adr-0044-revise-service-owned-note", + "adr-0053-temporal-matrix-skewed-zone", + "adr-0104-advertise-open-gates", + "adr-0104-attestation-adr-note", "adr-0104-d1-media-strict-per-deployment", "adr-0104-d1-value-shape-contract", + "adr-0104-d2-strict-by-default", "adr-0104-d2-typed-action-handlers", "adr-0104-d3-wave1-file-value-shape", "adr-0104-d3w2-pr1-upload-complete-fileid", @@ -96,9 +111,17 @@ "adr-0104-d3w2-pr3-file-ownership", "adr-0104-d3w2-pr4-governed-download", "adr-0104-d3w2-pr5a-write-cutover", + "adr-0104-d3w2-pr5b-enable-collection", + "adr-0104-d3w2-pr5b-engine-flag-read", "adr-0104-d3w2-pr6-backfill", "adr-0104-deployment-migration-gate", "adr-0104-design-doc-only", + "adr-0104-fresh-datastore-attestation", + "adr-0104-gate-announcement-tells-the-truth", + "adr-0104-meta-advertisement-as-implemented", + "adr-0104-remaining-flips-evidence", + "adr-0104-remaining-flips-revision", + "adr-0104-value-shapes-scan-gate", "adr-0105-d8-delegated-admin-org-role", "adr-0105-d8-issuance-resolves-issuer-grants", "adr-0105-d9-cross-org-approver-targeting", @@ -106,30 +129,64 @@ "adr-0105-group-posture-entitlement", "adr-0105-group-tenancy-phase-0-1", "adr-0105-group-tenancy-posture", + "adr-0105-status-phase2-d8-d9", "adr-0106-metadata-fls-masking", + "adr-0110-action-declaration-admission", + "adr-0110-draft-only", + "adr-0111-depth-share-management", + "adr-0111-draft-only", + "adr-0111-share-link-reshare", + "adr-0111-sharing-authorization-face", + "adr-0111-verb-boundary-delete", + "adr-0112-batch-1-screaming-codes", + "adr-0112-batch-2-lowercase-sweep", + "adr-0112-error-code-vocabulary", + "adr-0113-required-write-contract", + "adr-0114-field-error-catalog", + "adr-0114-field-errors-rename", + "adr-0115-docs-tail-corrections", + "adr-0115-plugin-dev-stub-table-verdict", + "adr-0116-followups", + "adr-0117-owning-business-unit", "agent-knowledge-alias-and-experimental-markers", + "agents-md-worktree-staleness", "agents-pd12-alias-retirement-path", + "agents-pd12-shim-retired", + "agents-spec-generated-artifacts-map", "aggregate-temporal-output", + "aggregation-vocabulary-lockstep", "ai-agent-authoring-and-tools-removal", + "ai-agents-envelope-declaration", + "ai-agents-fallback-envelope", "ai-agents-pending-actions-sdk", "ai-namespace-expresses-real-surface", + "ai-slot-remedy-tells-the-truth", "ai-surface-affinity-lint", "ai-tool-registry-and-lint", "ai-wildcard-to-zero", + "analytics-between-predicate-dropped", + "analytics-capability-conditional-mounting", "analytics-client-dispatcher-alignment", "analytics-cube-gate-and-error-leak", "analytics-effective-granularity", "analytics-execute-aggregate-execution-context", + "analytics-filter-operator-coverage", "analytics-label-read-scope", "analytics-objectql-read-scope", + "analytics-or-not-combinators", "analytics-order-by-display-label", + "analytics-query-bare-shape-entry-validation", "analytics-read-scope-bridge-order", + "analytics-timedimension-projection", "analytics-widget-query-options", "api-exposure-failopen-observability", "api-methods-derivation-contract", "apimethod-enum-shrink", "apimethods-batch-conformance-ratchet", + "app-dead-authoring-keys", "app-metadata-reference-integrity-assessment", + "app-navigation-strict", + "app-runtime-hooks-artifact-boot", "approval-action-hierarchy", "approval-actions-translation-bundle", "approval-actor-is-the-authenticated-caller", @@ -141,16 +198,22 @@ "approval-lock-schedule-run-provenance", "approval-participant-visibility", "approval-pending-approver-groups", + "approval-reassign-structured-parties", "approval-status-mirror-names-the-actor", + "approval-vocabularies-derived", "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", + "audit-provenance-and-import-vocabulary", + "audit-test-static-imports", + "auth-catchall-yields-unowned-paths", "auth-route-ledger", "auth-validationerror-4xx-mapping", "authorable-surface-ratchet", + "authoring-key-lint-full-coverage", "authz-ledger-flow-runas", "automation-client-resume-screen-flow", "automation-resume-authority-gate", @@ -159,73 +222,167 @@ "batch-dropped-fields-observability", "better-auth-1-7-0-rc-2-and-prod-dep-batch", "better-auth-team-member-count", + "body-write-lint-message-driver-truth", + "boot-api-merge", + "builtin-node-config-reconciliation", + "bulk-batch-size-cap", + "bulk-writes-bind-to-path-object", + "calendar-day-primitive-to-spec", + "calendar-day-upper-bound-memory-mongodb", + "calendar-day-upper-bound", + "calldata-query-fallback-serves-query", + "changelog-ships-in-tarball", "chartconfig-trim-zoom-clickaction", "ci-cache-tier1-optimizations", "ci-node-22-pin", "ci-node-eol-guard", "ci-performance-optimization", + "ci-rerun-safety-and-timeouts", "ci-test-completeness-guard", "cli-json-pipe-truncation-sweep", + "cli-stale-dist-tests-and-project-root", "client-actions-surface", + "client-error-envelope-normalisation", "client-keys-sharelinks-security", "client-meta-automation-descriptors", "client-packages-lifecycle", + "client-retires-parking-spot-read", "client-url-conformance-capstone", "close-approvals-and-record-shares-gaps", + "close-out-sweep-inert-keys", "close-sharing-rules-explain-search-gaps", "close-the-eight-reports-rest-gaps", "close-the-final-nine-rest-gaps", "close-the-nine-metadata-rest-gaps", + "connector-authoring-guide", + "connector-descriptors-meet-their-contract", "console-09c6a177bb4a", "console-1bb77aa24514", "console-2cb8d78e24ad", "console-4a4829d0ef39", + "console-7d9734d5e321", + "console-96ee72e85439", + "console-a136322f8723", + "console-bebaebd39ace-backfill", + "console-bebaebd39ace", + "console-c6cfdf1288b6-backfill", + "console-e651c936870e", + "console-pin-pr-gate", + "control-flow-form-zod-ledger", "control-plane-guard-crossref", "conversion-notice-channel", + "criteria-json-declaratively-required", + "cross-object-batch-501-code", + "current-user-endpoints-kernel-resolver", + "d12-fake-inventory-gate", "data-path-object-existence-gate", + "data-query-path-object", + "datasource-admin-503-names-its-own-service", "datasource-availability-observability", "datasource-bound-connect-failfast", + "datasource-routes-catch-service-throws", + "datasource-teardown-ownership", "date-bucket-parity-gate", + "date-now-default-utc", + "datetime-canonical-utc-storage", + "datetime-storage-form-memory-mongodb", "decision-outputs-surface-3447", + "declared-unique-index-not-legacy", + "default-datasource-adopt-seam", "default-datasource-declared", + "define-hook-factory", + "degraded-boot-stderr-premise", "delegable-scope-read-surface", + "delete-many-id-predicate", "department-approver-env-wide-business-unit", "deprecate-kernel-assignment-notifications", "deprecated-alias-conflict-rules", + "dev-plugin-production-hatch-brands", + "dev-plugin-protocol-family-removed", + "dev-plugin-security-stubs-and-prod-guard", + "discovery-data-slot-computed", + "discovery-metadata-slot-computed", + "discovery-remedy-names-a-real-package", + "dispatcher-call-sites-meet-their-contracts", + "dispatcher-envelope-shared-predicate", + "dispatcher-error-code-is-semantic", + "dispatcher-handler-ready-gate", "dispatcher-returned-error-leak", + "dispatcher-validation-error-fields", + "docs-accuracy-audit-4212-scope", "docs-audience-first-ia", + "docs-audit-4161-service-automation", + "docs-audit-changelog-non-exclusion", + "docs-drift-nested-package-roots", + "docs-drift-skip-test-files", "docs-fieldschema-extend-rot", + "docs-plugin-spec-onupgrade-example", + "docs-v17-plugin-retirement-notes", + "document-weak-pass-variants", "dogfood-gate-cancelled-not-failure", "dogfood-shared-boot", + "domain-error-passthrough", + "driver-conformance-gate", "driver-connect-bound-and-reconnect-correction", + "driver-options-bypass-tenant-audit", "driver-sql-logicalop-retention-note", "driver-sql-or-branch-and-semantics", "drop-dead-env-template-flag", "drop-dead-list-templates", + "drop-per-package-lint-scripts", + "drop-require-auth", + "drop-undeclared-actions-valve", "dropped-fields-bulk-graphql-client", + "empty-capability-answers-501", "empty-group-bucket-key-null", + "empty-state-gate-object-surface", + "empty-state-semantics-gate", "enforce-user-level-export-axis", + "engine-callsites-canonical-keys", + "engine-filter-fold-every-method", + "engine-rejects-unknown-option-keys", + "engine-rejects-wire-only-aliases", "engines-node-22", + "envelope-violations-predicate", "export-axis-opt-in", "export-empty-result-header", + "export-honors-search-term", + "exported-types-no-longer-resolve-to-any", "expression-approvers-3447-p2", + "external-datasource-400s-answer-their-own-code", "fault-edge-guard-containment", "fault-edge-label-lint", "field-conditional-required-fold", + "field-file-collection-dogfood-proof", "field-readonly-doc-preserveaudit", + "field-time-canonical-storage", "file-access-delegate", "filter-context-tokens-gate", "filter-logic-conformance-single-source", + "filter-no-silent-drop", "filter-tokens-runtime-resolver", + "find-data-wire-context", + "fix-stale-hono-changeset-ref", "fix-stale-scaffolder-changeset-refs", "fix-unmounted-local-file-url", + "flow-action-record-id-seeding", + "flow-create-record-write-lint", "flow-error-object-serialization", + "flow-executors-parse-config", "flow-filter-collapse-and-write-path-tokens", "flow-lookup-expand", + "flow-max-retries-single-default", + "flow-nested-region-walk", + "flow-node-config-alias-graduation", + "flow-node-expression-ledger", + "flow-node-write-set-lint", + "flow-run-summaries", + "flow-system-run-audit-attribution", "flow-template-filter-position-severity", "flow-template-lint-and-hydrate-guards", "flow-template-paths-into-reference-integrity-suite", "flow-trigger-unknown-event-lint", + "form-section-pane", "formview-buttons-defaults-live", "govern-report-dashboard-liveness", "govern-sys-member-writes", @@ -234,7 +391,20 @@ "group-union-driver-scope", "guard-refusal-chokepoint", "historical-import-audit-docs", + "honest-service-self-description", + "hono-current-user-endpoints-exported", + "hono-current-user-endpoints-ungated", + "hono-retire-standard-endpoints", + "hono-standalone-discovery-computed", + "hono-standard-endpoints-default-off", + "hook-bodies-write-set-docs", + "hook-body-write-set-lint", + "hook-empty-target-not-wildcard", + "hook-vs-flow-path-guidance", + "http-contract-unification", + "http-server-exemption-revoked", "i18n-bundle-drift-sweep", + "i18n-consolidate-success-builder", "i18n-coverage-ratchet", "i18n-extract-check-flag", "i18n-field-labels-emit-declared-shape", @@ -245,46 +415,86 @@ "i18n-translate-platform-bundles", "i18n-translation-item-shape-3778", "i18n-translations-request-drop-phantom-filters", + "i18n-view-coverage", "ihttpserver-contract-codify", "import-historical-audit", "import-historical-fsm", "import-sanitize-row-errors", "import-undo-preserveaudit", "index-drift-migrate-plan", + "inert-rule-warn-dedupe", + "inline-action-schema", "invitation-accepted-host-seam", + "io-node-config-reconciliation", "isLikelyEmail-no-control-char", + "job-placeholder-migrates-to-db-adapter", "job-retry-timeout-3494", + "keyset-batch-walks", "lazy-deps-dist-probe-timeout", + "lifecycle-event-registry-enforced", + "lint-fieldless-object-skip", "lint-flag-record-change-trap", "lint-reference-integrity-suite", + "lint-system-fields-derived", "lint-translation-reference-integrity", + "lint-unique-double-declaration", "list-column-prefix-summary-object", "liveness-evidence-path-resolution", "liveness-ledger-ai-scope-honesty", + "liveness-orphan-row-gate", "liveness-register-orphan-proofs", "liveness-ten-preview-claims", "liveness-verified-at-clock", + "localized-field-validation-messages", "manifest-bridge-arm-on-project-kernels", "marketplace-objects-bridge-metadata-service", "marketplace-rehydrate-seed-heal", + "marketplace-seed-test-budget", + "mcp-discovery-service-aware", + "measure-emits-what-it-declares", "membership-grade-not-capability-channel", + "memory-datasource-ephemeral-per-pool", + "memory-driver-opt-in-persistence", + "messaging-declares-its-event-object", + "meta-type-gate-plural", + "metadata-form-zod-reconciliation", + "metadata-remove-artifact-api-source", + "metadata-type-registration-names-a-real-hook", "metadata-unresolvable-posture-fail-closed", + "migrate-occupancy-and-deferred-ddl", + "migrate-occupancy-file-descriptor-signal", + "migrate-plan-lists-datetime-convergence", + "migrate-search-companion-parity", + "modal-actions-are-client-only", "mongodb-single-tenant-boot-guard", "naming-drift-recheck", "nav-access-lint", + "nav-item-input-type", "notifications-redos-fix", + "notify-source-shape-conversion", "objectchart-aggregate-result-columns", "objectchart-contract-back-to-spec-shape", "objectql-crossobj-capability", "objectql-crossobj-fail-closed", + "objectql-dead-use-retired", "objectql-driver-connect-failfast", + "objectql-engine-contract", "objectql-strategy-daterange", "osv-batch-2026-07-dep-bumps", + "packages-envelope-suite-comment", "page-field-and-chart-binding-lint", "page-header-i18n-3589", + "paged-read-determinism", + "per-package-typecheck-coverage", + "pin-control-flow-designer-forms", + "platform-always-on-capabilities", "platform-objects-app-i18n-phantom-debt", + "plugin-lifecycle-hooks-retired", + "plugin-ordering-declared-contract", + "plugin-ordering-provider-declarations", "plugin-page-i18n-drift-guard", "preserveaudit-test-and-docs", + "preview-omits-virtual-fields", "previous-null-on-create-leg", "prose-example-gate-covers-docs", "prune-aspirational-config-3494", @@ -296,33 +506,83 @@ "prune-report-aria-performance", "prune-report-column-grouping-schemas", "prune-skill-permissions", + "public-book-grant", + "published-files-whitelist", "purge-webhook-delivery-i18n-and-bundle-ownership-guards", + "query-ast-inert-request-surface", + "query-cursor-removed", + "query-distinct-removed", + "query-field-node-object-form-removed", + "query-having-enforced", "rbac-objects-bulk-primitive", + "react-block-field-props-lint", + "react-listview-searchable-fields-lint", + "react-page-props-joins-the-suite", "readme-fde-audience", "readonly-flow-write-json-warning", "readonly-flow-write-lint", + "readonly-flow-writes-joins-the-suite", "ready-probe-driver-health", + "reconcile-ledger-in-ci", "reconcile-packages-post-and-ui-view-dialect", "record-after-write-trigger", "record-change-hydrate-formula-fields", + "recursive-schema-input-pins", + "recursive-schema-input-types", "reference-integrity-object-and-action-names", + "reference-integrity-wiring-guard", "regenerate-ui-action-reference-doc", + "region-metadata-parity", + "region-slots-single-declaration", + "region-validator-coverage", + "register-field-file-collection-proof", "reject-body-on-non-script-action", + "reject-malformed-filter-array", "release-hotcrm-gate-premode", "remove-dead-client-surfaces", "remove-dead-sdk-surface", "remove-enable-trash-mru", "remove-graphql-surface", + "remove-unenforced-plugin-loading-config", "report-chart-dataset-describe", + "report-order-liveness-live", + "report-ordering-and-time-axis-default", + "request-body-schema-validation", + "required-decision-outputs", + "resolve-service-returns-its-contract", + "rest-actions-type-dispatch", "rest-env-resolution-kernel-resolver-seam", + "rest-list-implicit-filter-and-merge", + "rest-list-malformed-filter-rejected", + "rest-list-search-groupby-aggregations-rejected", + "rest-list-sort-select-expand-rejected", + "rest-list-unknown-query-params", "rest-patch-data-dropped-fields", "rest-route-ledger-audit-guard", "resume-gate-map-chain-and-reserved-vars", "resume-signal-chokepoint", + "retire-batch-validate-only", "retire-default-dispatcher-routes", + "retire-degraded-analytics-shim", + "retire-dev-analytics-stub", + "retire-dev-service-marker", + "retire-dispatcher-storage-bridge", + "retire-generated-paths-filter", + "retire-inert-driver-plugin-options", + "retire-the-dev-stub-table", + "retire-the-dispatcher-auth-mock", "retire-three-deprecated-aliases", + "retire-three-orphan-operator-vocabularies", + "retirement-prescriptions-name-protocol-17", + "rls-enabled-enforced-security-audit", + "rls-priority-removed", "route-audit-tranche-3-service-mounts", + "route-envelope-four-more-modules", + "route-envelope-guard-dispatcher-domains", "route-ledger-audit-guard", + "rpc-alias-precedence-one-fold", + "run-summary-uncountable-effects", + "runner-setters-first-wins", "runtime-action-execution-module", "runtime-actions-mcp-extraction", "runtime-auth-ai-extraction", @@ -333,54 +593,134 @@ "runtime-meta-data-extraction", "runtime-packages-extraction", "runtime-share-links-extraction", + "sandbox-structured-error-passthrough", + "savemeta-persists-normalized-operators", + "scaffolds-drop-memory-driver", + "schemaless-node-config-contracts", "scim-provider-key-and-sso-scim-parity", "scoped-invitation-placement", "screen-field-visible-when-on-the-wire", + "searchable-fields-stale-declaration", "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-dropped-reference-counter", "seed-loader-engine-schema-fallback", + "seed-loader-multi-value-lookup", "seed-state-machine-lint", "seed-summary-banner", "seed-summary-marketplace", "seed-writes-exempt-state-machine", + "serve-boot-log-visibility", + "serve-boots-without-artifact", "serve-fallback-declared-default", + "serve-named-artifact-and-ordering-truth", + "serve-no-artifact-definestack-pin", "service-error-envelope-conformance", + "service-lookup-any-guard", "service-storage-success-envelope", + "settings-error-details-declared-slot", + "share-link-routes-envelope", + "share-links-dispatcher-dual-key", + "shared-response-envelope-builders", "sharing-access-level-full-removed", + "sharing-rule-criteria-required", "sharing-rule-recipient-reconcile", "sharing-rule-unknown-sort-and-stale-help", "showcase-action-disabled-specimen", + "showcase-approver-and-picker-specimens", + "showcase-bulk-actions-example", + "showcase-global-action-specimen", + "showcase-legacy-rowactions-specimen", "showcase-nav-affordance-specimen", + "skills-definehook-examples", + "slot-contract-ledger-beyond-the-enum", + "slot-lookup-fourth-shape", + "slot-lookup-ratchet-enforcement", + "slot-lookup-sweep-auth-session", + "slot-lookup-type-argument-ratchet", + "slot-lookups-return-their-contract", + "sort-dotted-path-rejected", + "spec-changes-manifest-catchup", + "spec-check-generated-aggregate", + "spec-property-retirement-skill", "sql-driver-dialect-connect-timeout", "sqlite-datetime-date-bucket", + "sqlite-wal-journal-mode", + "stack-storage-not-an-authoring-key", + "stall-forensics-and-kernel-test-hygiene", + "stall-guard-rollout", + "standard-endpoints-parity-correction", + "standard-endpoints-precedence-pin", "startup-log-noise-cleanup", "step2-metadata-protocol-plugin", "step2-prc-single-source", + "storage-adapter-swap-verdict", "storage-download-filename", + "stored-metadata-replays-the-chain", + "strictness-ledger-gate", + "strictness-ledger-recursive-coverage", + "strip-read-decorations-on-save", + "sweep-close-out", + "sys-migration-ledger-platform-infra", + "sys-secret-store-platform-infra", "sys-view-definition-default-open", + "temporal-conformance-matrix", + "temporal-conformance-stall-guard", + "temporal-conformance-token-axis", + "temporal-docs-accuracy", + "temporal-dogfood-gate", + "temporal-hooks-on-contract", + "temporal-storage-form-axis-tests", + "temporal-time-axis", + "tender-hats-brush", + "test-core-stall-guard", + "tests-off-memory-driver", "tombstone-agent-tools", + "tool-inert-keys-removed", "tool-requires-confirmation-not-enforced", "tool-requires-confirmation-removed", "two-factor-lockout-and-object-translations", "two-factor-lockout-extension", "two-factor-lockout-follows-settings", + "type-blind-temporal-date-operands", + "typecheck-covers-the-test-layer", "typed-decision-outputs-3447", + "ui-discovery-reads-the-protocol-service", + "ui-vocabularies-derive-not-restate", "unique-tenant-scoped-materialization", + "unknown-authoring-key-lint", + "unknown-key-lint-nested-descent", + "unknown-key-strictness-data-step", + "unknown-key-strictness-step2", + "unknown-key-strictness-step3", + "unknown-key-strictness-tier-a", + "unknown-node-config-key-warning", + "unordered-paged-read-determinism", "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-page-console-gap-and-nav", + "v17-page-rc1-window", "v17-rc-anchor", + "v17-release-page", + "vacuous-filter-carveouts", "validate-runs-build-authoring-lints", + "variant-doc-drift-gate", + "variant-docs-exemption-audit", "verify-multitenant-requests-isolated-posture", + "view-ast-operator-parity", + "wait-loose-config-graduation", + "wait-timeout-keys-retired", "webhook-authoring-surface-bridge", "webhook-liveness-ledger-flip", "webhooks-drop-dead-delivery-i18n", + "wildcard-fallthrough-guard", "withdraw-adr-0107-drop-writes-proposal" ] } diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 056d4f619d..380a9f1f3c 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,190 @@ # @objectstack/example-crm +## 4.0.92-rc.1 + +### Patch Changes + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [698cbc2] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [a225ef5] +- Updated dependencies [c9d254a] +- Updated dependencies [c8124e5] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + ## 4.0.92-rc.0 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 64d06256b9..ced777f267 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.92-rc.0", + "version": "4.0.92-rc.1", "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 e69653ef5a..876ff5e0af 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,281 @@ # @objectstack/example-showcase +## 0.3.14-rc.1 + +### Patch Changes + +- e8d0c21: feat(spec): `FormSection.pane` — explicit split-pane placement (objectui#2153 follow-up) + + A `type: 'split'` form view had no way to say which pane a section renders in: + the renderer hardcoded "first section left, everything else right". That + positional rule is invisible in the metadata — nothing in the JSON records the + assignment — so reordering sections silently moved them across the divider, and + an author (human or AI) could not place two sections side by side on the left at + all. + + `FormSectionSchema` gains an optional `pane: 'primary' | 'secondary'`: + + - **Explicit and per-section**, so placement survives reordering and an agent + editing the view can see — and must preserve — where each section lives. + - **Omitted → the legacy rule** (first section `primary`, others `secondary`), + so existing keyless metadata keeps its exact layout. + - **Split-only, enforced loudly**: a `FormViewSchema` refinement rejects `pane` + on any other form type at parse (covering the legacy `groups` alias and the + defaulted `type: 'simple'`). "Accepted but ignored" is the failure mode this + key must never have — a silent no-op reads as working, especially to an AI + author. zod 4 keeps refinements through `.extend()`, so the flattened + runtime-overlay variant in `ViewMetadataSchema` enforces it too. + - Strict two-value enum, not free text — a typo (`'left'`) is a parse error. + + The `'split'` type's enum comment claimed "Master-Detail split"; master-detail + already has two homes (`subforms` on the form, related lists on record pages), + so the comment now states split's actual, non-redundant meaning: side-by-side + resizable panes with sections placed via `section.pane`. + + The showcase task form's `split` view previously declared a single section — + which renders as a plain (unsplit) form — and now demonstrates the feature: + two sections with explicit panes. + + Renderer support ships in ObjectUI (`SplitForm` → `FormSchema.fieldPanes`, + whose pane keys are already named `primary`/`secondary` — a 1:1 mapping). + +- d6bfb3d: refactor(spec)!: remove the RLS-policy `priority` key — it promised conflict resolution that cannot exist (#3896 audit) + + `RowLevelSecurityPolicySchema.priority` was documented as _"Policy priority for + conflict resolution"_. The 2026-07-30 security-subset liveness re-verification + found that **nothing ever read it** — and, stronger, that nothing ever could: + applicable policies **OR-combine** (any match allows access, most permissive + wins — the schema's own describe said so), so there is never a conflict to + order and evaluation order cannot change an outcome. A semantically-void knob + on a security policy is worse than dead: an author — very often an AI + (ADR-0033) — reads it as a precedence lever and reasons about policy + interactions that do not exist. + + Removed per the `tool.requiresConfirmation` (#3715) / `DynamicLoadingConfig` + (#3950) precedent, inside the v17 breaking window: + + - **Tombstoned, not silently stripped** (`retiredKey`, #3855 pattern): an + authored `priority` fails `tsc` (the input type is `never`) and rejects at + parse with the prescription itself — _"policies OR-combine (most permissive + wins), so there is no conflict to order. Delete the key — policy outcomes are + unchanged."_ + - **ADR-0087 D2 conversion + D3 chain step** (`permission-rls-priority-removed`): + `os migrate meta` deletes the key from authored sources mechanically — a pure + lossless delete, no semantic residue. spec-changes.json and the protocol + upgrade guide carry the entry. + - The policy factory helpers (`ownerPolicy`, `tenantPolicy`, …), the showcase + example's permission sets, and `content/docs/permissions/rls.mdx` no longer + author it; the docs table's `enabled` row now states the (since-enforced) + contract instead. + - Liveness ledger entry updated to record the removal; the tombstone and entry + age out ~two majors from now. + + Dropping the key changes **no policy outcome anywhere** — that impossibility of + effect is the entire reason for the removal. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [698cbc2] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [a225ef5] +- Updated dependencies [7bf5349] +- Updated dependencies [366105c] +- Updated dependencies [c9d254a] +- Updated dependencies [42e3b01] +- Updated dependencies [c8124e5] +- Updated dependencies [39eb01b] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [ac1cc8c] +- Updated dependencies [99b4392] +- Updated dependencies [974c6d4] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [9e01213] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [3fe0ff1] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [c53aa53] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/driver-sql@17.0.0-rc.1 + - @objectstack/cloud-connection@17.0.0-rc.1 + - @objectstack/connector-mcp@17.0.0-rc.1 + - @objectstack/connector-openapi@17.0.0-rc.1 + - @objectstack/connector-rest@17.0.0-rc.1 + - @objectstack/connector-slack@17.0.0-rc.1 + - @objectstack/service-datasource@17.0.0-rc.1 + ## 0.3.14-rc.0 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 2e3c3f9bdd..b2f9d3b936 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.14-rc.0", + "version": "0.3.14-rc.1", "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 592a6987e0..d73a0c11e3 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,232 @@ # @objectstack/example-todo +## 4.0.92-rc.1 + +### Patch Changes + +- c5c78bb: **[#3959] `app-todo`'s `defer_task` / `set_reminder` are `type: 'script'`, not `type: 'modal'`.** + + Both declared `type: 'modal'` with a `target` naming a modal page that does not + exist (`defer_task_modal`, `set_reminder_modal`), while their handlers sat + registered under `deferTask` / `setReminder` — keys no declaration could + address. A `modal` action has no server dispatch (`headlessActionTypeError` + rejects it over REST), so neither handler had ever executed: the example + shipped business logic that could not run, and ADR-0110 D5's boot inventory + flagged both on its first pass. + + Both already declared the `params` their handlers read, so they were always + "collect input, then run server-side" actions — which is `type: 'script'` with + `params`. The runner collects the same dialog and the handler now actually runs. + + The action-type table in `content/docs/ui/actions.mdx` said `modal` meant + "collect input, then submit to a handler", contradicting the same page's own + REST table (`modal` → 400, nothing for the server to run). Corrected. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [3ec8186] +- Updated dependencies [698cbc2] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [cbc08eb] +- Updated dependencies [0c4f5b2] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [a225ef5] +- Updated dependencies [c9d254a] +- Updated dependencies [c8124e5] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [9881074] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [10575f3] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [ac6c0be] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [c53aa53] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/client@17.0.0-rc.1 + - @objectstack/metadata@17.0.0-rc.1 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.1 + - @objectstack/knowledge-memory@17.0.0-rc.1 + - @objectstack/mcp@17.0.0-rc.1 + - @objectstack/service-knowledge@17.0.0-rc.1 + ## 4.0.92-rc.0 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index c11fbeac9a..ec2bd80645 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.92-rc.0", + "version": "4.0.92-rc.1", "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 f48411528f..13e05656e1 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,161 @@ # @objectstack/example-embed-objectql +## 0.0.32-rc.1 + +### Patch Changes + +- Updated dependencies [6a67d7a] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [3ec8186] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [b3a2318] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [9e8f04d] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/driver-memory@17.0.0-rc.1 + ## 0.0.32-rc.0 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index cc02a45c00..3fbc732688 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.32-rc.0", + "version": "0.0.32-rc.1", "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 8876a06a2e..1a07281c48 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,322 @@ # @objectstack/hono +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 4be9d99: fix(runtime,hono,plugin-dev): retire the dispatcher's `/storage` bridge — it never spoke the storage contract (#4087) + + `POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` were a + dispatcher-side bridge to the `file-storage` service slot, written against a + service shape that does not exist: + + - **Upload** called the contract's `upload(key, data, options?)` as + `upload(file, { request })` — the parsed file object landed in the `key` + slot and `{ request }` in `data`. That is a `TypeError` against every + implementation in the repo (`S3StorageAdapter`, `LocalStorageAdapter`, + `SwappableStorageService`, plugin-dev's in-memory one), not a + near-miss: `Buffer.from({}) → ERR_INVALID_ARG_TYPE`, or an object used as + an S3 object key / `path.join` segment. + - **Download** branched on `result.url` / `result.redirect` / `result.stream` + / `result.mimeType` while the contract's `download(key)` resolves a + `Buffer`, so every branch fell through and the route answered a + JSON-serialized Buffer. + + Both routes are removed, along with `HttpDispatcher.handleStorage()`, the + `/storage` domain registration, the dispatcher-plugin mounts and the two route + ledger rows. + + **Migration.** There is nothing to migrate off in practice — neither route + could complete a request. (They were reachable: `service-storage` mounts + `/storage/upload/presigned`, not `/storage/upload`, so nothing shadowed them. + They simply had no caller — no SDK method builds those URLs.) + `/api/v1/storage` is `@objectstack/service-storage`'s surface and always was + the working one: + + - Upload — FROM `POST /api/v1/storage/upload` TO the presigned protocol + (`POST /storage/upload/presigned` → direct `PUT` to the returned URL → + `POST /storage/upload/complete`), or `client.storage.upload(file)`, which + runs all three steps. + - Download — FROM `GET /api/v1/storage/file/:id` TO + `GET /storage/files/:fileId/url` (`client.storage.getDownloadUrl(fileId)`) + for a signed URL, or `GET /storage/files/:fileId` for a stable browser URL + that 302s to it. + + Install `@objectstack/service-storage` to get those routes; without it + `/api/v1/storage` now has no handler, which is the same answer every other + uninstalled capability gives. + + Two follow-on corrections keep `declared === enforced`: + + - `@objectstack/hono` no longer mounts `app.all('/storage/*')`. That + wildcard claimed the whole `/storage` subtree for the two dead routes, so + every other path under it — service-storage's protocol above all — got the + bridge's own 404 rather than falling through. Storage is ordinary catch-all + traffic now. + - Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared + `handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory + implementation now self-declares `handlerReady: false`. #4058 deliberately + left that one serving because the `/storage` bridge was still there to serve + it; with the bridge retired nothing routes HTTP to that slot, so `false` is + the honest value — the position `realtime` has held since ADR-0076 D12. The + implementation keeps working for in-process callers; it is simply no longer + advertised as a reachable HTTP capability. + +### Patch Changes + +- 554ff92: fix(adapters/hono): the auth wildcard yields paths the auth service does not own (#4117) + + `app.all('${prefix}/auth/*')` claimed a whole namespace and was **terminal**: it + returned the auth service's response unconditionally, including better-auth's 404 + for a path it does not implement, and the legacy `handleAuth` bridge's own + `handled: false` 404. That is the #4088 shape, found by #4116's enumeration after + manual greps had missed it. + + A 404 from better-auth, or `handled: false` from the dispatcher, now means "not + this mount's path" and the handler yields. The predicate is the dispatcher's own + `handled` flag wherever one exists — an explicit ownership answer beats inferring + one from a status; only the better-auth hand-off lacks such a flag, and there the + 404 is the signal, as in #4092. + + **What changes on the wire.** An unowned path under `${prefix}/auth/*` used to get + a 404 built by this mount. It now continues to the `${prefix}/*` dispatcher + catch-all and gets a real, gate-carrying `dispatch()` attempt, so a domain handler + registered for such a path becomes reachable — this adapter's actual extension + mechanism. When nothing anywhere claims the path the reply is still the same + enveloped `{ success: false, error: { message: 'Not Found', code: 404 } }`. Paths + the auth service does own are untouched, and a 401/403 from it is never treated as + a disclaimer of ownership. + + No configuration changes and no new routes. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 2053714: fix(hono,plugin-hono-server,runtime): one CORS source and one registry key — the last derivable copies from the #3786 sweep + + Re-ran the sweep across all 72 packages. The earlier pass globbed `packages/*/src`, + which is one level deep, so it missed everything under `packages/plugins/` and + `packages/adapters/` — the "sweep is basically clean" report was based on an + incomplete scan. + + **A stale CORS default, on the one description callers actually read.** + `HonoCorsOptions.allowHeaders`' TSDoc promised + `['Content-Type', 'Authorization', 'X-Requested-With']` "which is sufficient for + cookie and bearer-token auth". The real default carries three more: + `X-Tenant-ID` and `X-Environment-Id` (multi-tenant routing) and `If-Match` (the + OCC token on record PATCHes, objectui#2572). Sizing a custom `allowHeaders` + against that sentence drops all three and every cross-origin save fails with + "Failed to fetch". + + The instructive part: **three** Hono CORS sites each carried their own copy of + the defaults under "keep in sync" comments, and the copies all agreed. What + drifted was the _doc_ — the only description with no counterpart to be diffed + against, and the only one a caller reads. + + Both defaults are now single constants, `DEFAULT_CORS_ALLOW_HEADERS` and + `DEFAULT_CORS_EXPOSE_HEADERS`, exported from `@objectstack/plugin-hono-server` + and imported by the adapter (which already depends on it — no new edge). The + TSDoc links them rather than restating, and documents an asymmetry it never + mentioned: `allowHeaders` REPLACES the default, `exposeHeaders` MERGES with it. + + `hono-plugin.test.ts` stopped stubbing `./adapter` wholesale and keeps the real + constants via `importOriginal` — it asserts exact header lists, so a mocked copy + would make the test agree with itself rather than with what ships. Verified: + removing `If-Match` from the constant fails `should allow If-Match by default`, + by name. + + **A third copy, in the public protocol docs.** `content/docs/protocol/kernel/ +http-protocol.mdx` advertised `Access-Control-Allow-Headers: Authorization, +Content-Type` — two of the six — and methods missing `PUT` and `HEAD`, with no + mention of the exposed headers at all. That is the copy an integrator builds a + client against: reading it, you would not know `If-Match` is permitted (so you + would not attempt OCC) or that `set-auth-token` is readable (so a rotated + session would look like a bug). Corrected, with the three non-obvious allowed + headers and the two exposed ones explained, and a pointer to the constants as + the source of truth. + + **A hand-copied service-registry key.** `runtime`'s share-links domain resolved + `'shareLinks'` as a string literal, copied from `SHARE_LINK_SERVICE` — whose own + doc-comment says "keep in sync with the SharingPlugin registration". It now + imports the constant. A drifted copy resolves nothing, so every share link + answers 501 "Sharing is not configured for this environment" on an environment + where it is configured perfectly well. + + **Plus a duplicate ledger entry**, which is the same defect one level up: + `check-generated.ts` carried two `NO_GENERATOR` entries for + `check:strictness-ledger`, because #4203 and #4252 each added one without seeing + the other. Functionally harmless (the ledger is read into a `Set`) but it leaves + two comments telling overlapping versions of the same story. #4203's is kept — + it is the more complete account and it is the PR that fixed the underlying + problem. + + Checked and deliberately left alone: `ApprovalStatus` (5 values) and + `ApprovalActionKind` (12 values) versus their `plugin-approvals` selects — diffed + verbatim, no drift today, still hand-copied across a package boundary. + +- Updated dependencies [bc35e00] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [698cbc2] +- Updated dependencies [ffb003c] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [41dcda3] +- Updated dependencies [545d931] +- Updated dependencies [a225ef5] +- Updated dependencies [c9d254a] +- Updated dependencies [c3bcb42] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [4dc14cc] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [b5f9397] +- Updated dependencies [385c4b0] +- Updated dependencies [45dc446] +- Updated dependencies [d4720ca] +- Updated dependencies [43ff598] +- Updated dependencies [e5a4d26] +- Updated dependencies [839982e] +- Updated dependencies [623e555] +- Updated dependencies [f985b3f] +- Updated dependencies [9881074] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [7ce02eb] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [a1b61e0] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [a3cb9c8] +- Updated dependencies [4be9d99] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [a2266a6] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [627b188] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [2053714] +- Updated dependencies [7309c81] +- Updated dependencies [43fc039] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/plugin-hono-server@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index f1e2d32ddb..6d91148f91 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ae724be04f..7791582fa8 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,178 @@ # @objectstack/account +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 135660b4f2..4aeec45021 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 69f640dba7..8673baf977 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,178 @@ # @objectstack/setup +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index cd4691dfef..e4b55d1c77 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 5740b4cc47..f1659df075 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,178 @@ # @objectstack/studio +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index ce77eed6fb..c463701531 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 aa88f29c1d..5d8fb927cc 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1452 @@ # @objectstack/cli +## 17.0.0-rc.1 + +### Minor Changes + +- 3aef718: feat(migrate): `os migrate value-shapes` — the per-deployment gate for reference and structured-JSON value shapes (#3438) + + The second of ADR-0104 D1's two evidence gates. Media value shapes already + enforce once a deployment has verified its file migration (#3681); the + reference (`lookup` / `master_detail` / `user` / `tree`) and structured-JSON + (`location` / `address` / `composite` / `repeater` / `record` / `vector`) + classes now get a gate of their own. + + ```bash + os migrate value-shapes # scan: reports, writes nothing + os migrate value-shapes --apply # scan + record the deployment flag when clean + ``` + + The run walks every stored value of those classes against + `valueSchemaFor(field, 'stored')` — the same predicate the write path enforces, + imported rather than re-derived — and, at zero violations, records + `sys_migration { id: 'adr-0104-value-shapes', verified_at, blocking: 0 }`. + Strict enforcement of these classes reads **that row**, never the platform + version, so upgrading changes nothing until a deployment produces its own + evidence. + + **There is no backfill, deliberately.** The file migration converts legacy + values because the platform narrowed that storage form and owes the conversion. + A malformed `location` is application data whose correct value only its author + knows, so this run reports and prescribes — naming the object, field, type, + count, offending record ids and the parse issue — and the operator fixes and + re-runs. With nothing to convert, `--apply`'s only write is the flag row, which + keeps the #3617 invariant trivially: a dry run changes nothing, and whether a + run changed this deployment's posture never depends on what it found. + + **A separate flag from the file migration**, because it attests a separate + fact. That flag says file values were migrated and their ownership reconciled; + it says nothing about whether a `lookup` id or a `location` payload is well + formed. Gating these classes on it would be borrowing evidence for a fact it + does not cover. + + - New escape hatch **`OS_ALLOW_LAX_VALUE_SHAPES=1`** returns a verified + deployment to warnings, with the same precedence as its media sibling: the + opt-out beats `OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, which beats the flag. + Wrongly staying lenient costs a warning; wrongly enforcing stops a working + app from writing. + - `@objectstack/spec/system` exports `VALUE_SHAPES_MIGRATION_ID`. + - `@objectstack/objectql` exports `scanValueShapes`, `valueShapeScanPassed` + and `formatValueShapeScanReport`. The scanner is read-only and does **not** + record the flag: readers of a migration flag use the spec contract, only + writers depend on `@objectstack/platform-objects`, so the composition lives + with the CLI command rather than inverting the engine's dependencies. + - `validateRecord` gains `valueShapeStrict`, the sibling of + `mediaValueShapeStrict`. Both default to `false`: a caller that cannot say + stays lenient, so nothing starts rejecting merely because the evidence was + unavailable. + + **Nothing changes for an existing deployment until it runs the command.** A + scan that is truncated, or that cannot read an object, fails the gate even with + zero violations found — "none in the part we read" is not the claim the flag + makes. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- 33a5ff4: `os migrate` no longer touches the database before you confirm, and refuses a + SQLite database another process is using (#3917). + + **Nothing is written before the prompt.** `plan` called itself a dry run and + `apply` gated on `[y/N]`, but both booted the full plugin set first — and boot + schema-sync issued create-table/add-column DDL (plus the artifact's inline seed + wrote rows) against the target database before either promise was kept. + `SqlDriver` gains `setDeferredDdl` / `previewDeferredSchemaWork` / + `flushDeferredSchemaDdl`: while armed, `initObjects` still registers every + in-memory map drift detection depends on but records the physical work instead + of performing it. Both commands boot with it armed, render the held-back work + as a `New (additive)` section of the plan, and `apply` performs it only after + confirmation. `os meta resync` / `os migrate files-to-references` keep the old + behaviour — they need the tables to exist. + + **Occupancy check.** A live `os dev`/`os serve` holding the same SQLite file is + the usual way a migration goes wrong: the migration is transactional and swaps + tables inside the file, but the running server keeps prepared statements and a + schema cookie the migration invalidates. `os migrate` now probes the target + before booting — `PRAGMA locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` under + `busy_timeout = 0`, which reports `SQLITE_BUSY` when another connection is + _attached_, not merely writing. (`wal_checkpoint(TRUNCATE)` only sees an active + writer, and `-wal`/`-shm` presence cannot tell a live server from a crashed one; + both are encoded as tests.) `apply` refuses with exit 1 — `error: database_busy` + under `--json` — unless the new `--force` flag is passed; `plan` warns and + continues, since it writes nothing either way. SQLite only: Postgres and MySQL + take their own server-side locks. + + `@objectstack/runtime` also exports `resolveStandaloneDatabase()`, so a caller + can resolve the database target with the same precedence the boot uses without + building the stack, and `createStandaloneStack` accepts `skipSeedData`. + +### Patch Changes + +- 3ec8186: feat(migrate,objectql): the upgrade path names the data migrations that are still open here (#3438, ADR-0104 2026-07-30) + + Both value-shape gates fail toward leniency: a deployment that never runs its + migration keeps warning instead of rejecting, and keeps every released file + forever. That default is right — and completely silent, so the gate could sit + open for the life of a deployment without anyone learning that one command ends + it. A gate nobody is told about is served by nobody. + + Two announcements, each where an upgrade actually looks: + + - **`os migrate meta --from 16`** now ends by naming the data migrations a + chain crossing into 17 leaves behind — `files-to-references`, `value-shapes` + — with what each unlocks, scoped to the field classes the author's own + metadata declares (an app with no media field is never told about the file + migration). `--json` carries the same list as `dataMigrations`. The command + reads no database, so it reports what remains _to do_, never what a given + deployment has _done_. + - **The server logs one line per open gate at boot**, naming the command that + closes it. Only the lax posture announces itself — a verified gate already + logs that it is enforcing, and an app declaring neither class of field costs + nothing and says nothing. This is the half that can speak to a deployment's + actual data, because it is the half with the database. + + Nothing about enforcement changes: same gates, same flags, same fail-toward- + leniency default. The advisory runs on `kernel:bootstrapped` rather than + `kernel:ready`, deliberately — the answer depends on the storage service's own + ready handler, which registers `sys_migration` and may attest a store it just + created, and racing it would tell a brand-new deployment its gates are open + moments after they closed. + +- 49c9a1a: **A config-booted app no longer loses its `onEnable` — every `script` action's + handler reaches the engine again instead of 404'ing at dispatch (#4095).** + + `os serve ` calls `createStandaloneStack()`, which reads + `dist/objectstack.json` and returns a ready-made `AppPlugin` for the app. That + satisfied serve's "does the host already wrap itself with an AppPlugin?" guard, + so the `new AppPlugin(config)` built from the LOADED MODULE — the only one + carrying the module's `onEnable` — was skipped. A JSON artifact cannot hold a + function, so the app booted with all of its metadata and none of its code. + + On `examples/app-todo` that meant eight declared `script` actions, zero + registered handlers, and every button answering + `404 Action 'complete_task' on object 'todo_task' not found`. The example is + correctly authored: it declares `target: 'completeTask'`, registers + `todo_task:completeTask`, and exports `onEnable`. serve carried that hook intact + all the way to the branch that discarded it. + + Serve now grafts the module's executable members onto the app bundle already + registered, rather than dropping them with the wrap: + + - Only members `AppPlugin` actually executes travel — `onEnable` and the + `functions` map that string-named hook/job handlers resolve against. (`onDisable` + is deliberately excluded: it is declared in `packages/spec` but no kernel, + runtime or service ever calls it, so grafting it would wire a hook nothing + runs.) + - The artifact stays the metadata source of truth. Neither side is a superset — + the artifact carries compile-time enrichment the config never has (ADR-0046 + packaged docs, which serve already grafts the other way) — so this moves code + only, and never metadata. + - Targeting is by `manifest.id`, so a host composing several `AppPlugin`s can + never have one app's handlers attached to another. With no id to match, it + falls back to the single app bundle present and refuses when there are several. + - A bundle's own value always wins, so a host that wrapped itself on purpose is + untouched. + - Code that finds no bundle to land on is now reported with a boot warning naming + the consequence ("they 404 at dispatch") instead of vanishing. That silent drop + is what hid this. + + Verified end to end on `examples/app-todo`: `POST /api/v1/actions/todo_task/complete_task` + went from `404 RESOURCE_NOT_FOUND` to `{"success":true}`, `export_csv` now returns + real CSV, and the `[action-governance]` boot warning naming all eight actions is + gone. 14 unit cases pin the graft and — as importantly — the cases where it must + refuse; one end-to-end case boots a real stack through `bin/run-dev.js` and fails + against the pre-fix command. + + Note that `os serve ` still cannot boot at all when `dist/objectstack.json` + is absent (#4085, `Service 'manifest' is async - use await`). That was verified to + be a **separate** defect on the other side of the same fork, not this one: the + failure reproduces unchanged with this fix applied. + +- c6c0d05: fix(cli): the boot merge no longer discards the authored `api` block (#4002) + + `objectstack serve` (and `dev`, which spawns it) assembled the effective config as + `{ ...authored, ...bootResult }`. `createStandaloneStack()` / + `createDefaultHostConfig()` return an `api` block carrying only the + environment-scoping decision — `{ enableProjectScoping: false, projectResolution: +'none' }` — and under a shallow spread that object REPLACED the author's entire + `api`, silently dropping every key it did not itself set. + + Two of those keys are live knobs the CLI reads a few lines later: + + - **`api.requireAuth`** — the documented one-line opt-out for serving data + publicly (ADR-0056 D2; the v12 migration note presents it as the whole + migration). Authoring it did nothing: the value never reached the REST or + dispatcher plugin, so anonymous requests kept getting `401` **and** the boot + warning that exists to make a fail-open posture visible never fired either. + - **`api.enforceProjectMembership`** — the ADR-0024 D9 opt-out from the + `sys_environment_member` 403 gate. Silently fell back to the dispatcher default. + + `api` now merges per key, via a small pure `mergeBootConfig` helper: the author's + declarations survive, and the boot builder still wins on the keys it actually + decides (environment scoping is not the author's call on a standalone host). + Every other top-level key keeps the previous whole-value semantics — the + artifact-serve path deliberately serves the boot result's `objects` / + `permissions` / `manifest` / `plugins`, so those are untouched. + + The auth-less carve-out was never affected and is unchanged: it lives in the + `?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false)` fallback, which fired + precisely _because_ the authored value had gone missing. Only an explicitly + authored value was lost. + + Verified end to end: with `api: { requireAuth: false }` on the CRM example, an + anonymous `POST /data/crm_account/query` returned `401` before and returns records + after. Worth knowing what the working flag does — the same anonymous caller can + then read `sys_user` — which is the flag's documented meaning ("serve data + publicly"), and the argument for retiring it (#3963). + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7309c81: fix(runtime,cli): `projectRoot` reaches the metadata repository; stop compiling tests into the CLI's dist (#4065) + + Two defects behind the last of #4065's stray `.objectstack/` directories — the + one under `packages/cli/`. Neither is cosmetic. + + **1. `projectRoot` only got half the stack.** `createStandaloneStack`'s + `projectRoot` is documented as scoping a boot's on-disk state to the project + folder "so different examples / apps don't share a single database by accident", + and it did redirect the default sqlite database. But it was never passed to + `MetadataPlugin`, whose `FileSystemRepository` kept rooting at `process.cwd()`. + So one "project root" meant two different directories: a boot pointed at project + A wrote `A/.objectstack/data/` and `/.objectstack/metadata/`. It now + forwards `rootDir`, and `bootSchemaStack` accepts a `projectRoot` to pass down + (defaulting to `process.cwd()`, which is right for every real `os migrate` — the + CLI runs from the project directory). The two migrate integration suites, which + build a fixture project in a tempdir, now scope their boots to it. + + **2. The CLI compiled its own tests into `dist/` — and vitest ran them.** + `tsconfig.build.json` included all of `src` with no exclude, so every + `src/**/*.test.ts` was emitted as `dist/**/*.test.js`. Two consequences: + + - `files: ["dist"]` **published** them. + - This package has no vitest config, so `vitest run` collected the compiled + copies alongside the sources: **81 test files and 849 tests where the sources + hold 58 and 581**. Every `src/` test also ran as a stale `dist/` twin built + from whatever the source said at the last build. + + That is not just noise — it silently defeats edits. A fix to a source test + appeared not to work, because the run was still executing the pre-fix compiled + duplicate; that is exactly how the `.objectstack` residue survived a correct + fix long enough to look like a different bug. It also means a source test could + be edited to pass while its stale twin kept asserting the old behaviour, and + neither would be obviously wrong. Test files are now excluded from the build. + + No other package is affected: the rest build with `tsup`, which emits only + declared entry points. Verified by scanning every `packages/*/dist` for + `*.test.js` — the CLI was the only hit. + +- c20b875: **Correct the stale premise left behind by #4012: the degraded-boot stderr copy + survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.** + + `emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to + stderr in addition to `logger.warn`, and every comment and test name explaining + why cited the same reason: `os serve` swallowed all of stdout while the kernel + booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window + now buffers and replays `warn`-and-above — which retires the _stated_ + justification for a duplicate that is nonetheless still load-bearing: + + `Logger.write()` returns before touching a stream when the record is below + `config.level`, so at `--log-level error`, `fatal` or `silent` the banner's + `logger.warn` reaches **no** stream at all. A production host at `error` is + exactly the deployment this escape hatch exists for, and exactly where a + logger-only banner would vanish. Removing the stderr copy on the strength of + #4012 would therefore have been a regression — so this documents the reason that + is still true, in the places someone would read before deleting it: + `degraded-boot.ts`, the engine's emit site, and all three parity tests + (objectql, runtime, service-datasource), which are renamed off "which `os serve` + boot-quiet cannot swallow" to "which the operator log level cannot filter away". + + The objectql parity test now proves the claim instead of asserting around it: it + drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on + stderr _and_ nothing on stdout. Set the level to `warn` and it fails — so the + test is pinned to the level filter rather than passing for any reason. + + Also corrected in the same sweep, all comment-only, all previously overstating + what #4012 had not yet fixed: + + - the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the + boot window swallowed the engine's binding warnings. Its real justification is + stronger and unchanged: a flow that silently fails to arm emits **no** log line + at any level, so binding state has to be read off the live engine — absence of + a warning was never evidence of a bound flow. + - the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s + seed-outcome note attributed the silence to the boot window; the operative + gate is that `SeedLoader`'s result logs are `info`, under the default `warn`. + + No behavior changes. + +- 9774b78: fix(driver-sql): `Field.time` gets a canonical storage form — `HH:MM:SS[.fff]` wall-clock text on every dialect (#3994) + + `Field.time` repeated the pre-#3912 `Field.datetime` pattern: writes were never + normalised and only reads were repaired, so one SQLite column accumulated bare + time-of-day TEXT, full-timestamp TEXT and INTEGER epoch ms side by side. + `find()` looked right; everything that compared the STORED form was wrong — + measured: a business-hours window filter silently dropped 4 of 7 rows, ORDER BY + sorted 14:30 before 08:00, a full-ISO write failed the statement outright on + both Postgres and MySQL, a bound `Date` stored a process-timezone wall clock on + pg, MySQL's bare `TIME` rounded `…00.500` up to `…01`, and a `NOW()` default + resolved against three different clocks on the three dialects. + + The #3912→#3942→#3954 construction, transplanted (ADR-0053 D-C1..D-C3): + + - One `canonicalTimeOfDay` — `HH:MM:SS`, `.fff` only when non-zero; `Date`/ + epoch/full-timestamp fold to the UTC time-of-day — applied on write + (`formatInput`), to filter comparands (`coerceFilterValue`, and thereby the + `temporalFilterValue` contract hook) and on read (`toTimeOnly`). + - SQLite: legacy columns converge at schema sync (`backfillCanonicalTimes`, + same `IS NOT`-guarded UPDATE, same log-and-swallow policy); until then the + filter paths wrap the column in the repair expression — correct, just + unindexed. `os migrate plan` lists the work as `normalize_time_storage` with + a row count. + - MySQL: new time columns are `TIME(3)`; legacy `TIME(0)` columns widen at + schema sync (`migrateMysqlTimeColumns`, plan kind `widen_time_columns`), + since zero-precision TIME _rounds_ fractional writes. + - `NOW()` defaults read the UTC clock on every dialect (Postgres previously + used the server zone, MySQL the inserting session's zone — and MySQL 8.0 + rejects a plain `CURRENT_TIMESTAMP` default on TIME entirely). + - `distinct()`/`aggregate()` present time columns exactly as `find()` does. + + `HH:MM:SS` writes round-trip byte-identically (the field-zoo `f_time` + contract); a minutes-only `HH:MM` now completes to `HH:MM:00`, and uninterpretable + values still pass through untouched. + +- 9a4932a: fix(cli,spec): i18n coverage actually gates view labels — the `defineView()` container is no longer skipped (#4123) + + `i18n/missing-view` had **zero producers**. `collectExpectedEntries` recognized + two view shapes and the compiled config is neither: + + 1. **Object-nested `listViews`** — objects do not carry `listViews` once + compiled (0 across every example). + 2. **Top-level named views** — guarded by `if (!view?.name) continue`. + + `defineView()` emits the aggregated View **container**, `{ list, listViews, +formViews }`, which per spec (`view.zod.ts`) has **no top-level `name`**: it is + keyed implicitly by its target object at `list.data.object`, exactly as + objectql's `resolveMetadataItemName` resolves it. So the guard rejected the + spec's own container shape, and with it every view in every example — 64 view + strings that the ratchet reported as fully covered. + + The walker now handles the container, emitting under the same + `objects.._views..*` convention the runtime resolver reads + (`viewLabel` in `@object-ui/i18n`) and the shipped platform bundles already + carry. An unnamed default `list` resolves under `_views.list`, matching the + console's `primary.name || 'list'`. `formViews` stays uncovered — form views + have no counterpart in that resolver convention, so keys for them would expect + translations nothing reads. + + `StrictObjectTranslation` gains the `_views` slot that + `ObjectTranslationDataSchema` already permits. Without it, `satisfies +StrictObjectTranslation<…>` rejects the very translations the gate now asks + for. + + The newly surfaced strings are **translated, not ratcheted** (the precedent set + when the object-less action landed): `check-i18n-coverage` stays at 665 with + none new. + +- ec36ba8: feat(cli): lint the contradictory uniqueness double-declaration (#3991) + + New advisory rule `unique/double-declaration`, reported by `os lint` and + `os build`. It fires when one column carries BOTH a field-level `unique: true` + and an object-level single-column unique index: + + ```ts + email: Field.email({ unique: true }), // per-tenant since #3696 + indexes: [{ fields: ['email'], unique: true }], // platform-wide, verbatim + ``` + + The two spellings deliberately mean different things (see `IndexSchema`), and + each is legitimate alone. Together on one column they never are: + + - On a **tenant-scoped** object they contradict. The stricter one wins + physically, so the global index enforces uniqueness and the per-tenant + composite becomes a constraint nothing can trip — one of the two authored + intents is silently discarded. Worse, it hides the #3696 semantic change: + the switch from global to per-tenant has _no observable effect_ while the + declared index still enforces the old behaviour, so the author never learns + their tenancy model and their real constraint disagree — until a second + tenant reuses the value and is rejected. + - On a **tenancy-less** object they are the same index declared twice. + + Tenancy is deliberately not inferred at authoring time (`organization_id` is + injected by the kernel at registration, not authored), so the message names + both readings and the fix spells out the choice: `unique: 'global'` plus + dropping the index for platform-wide, or dropping the index for per-tenant + (or writing it out as `fields: ['organization_id', 'email']`). + + A field already declared `unique: 'global'` is exempt — the index restates + that intent rather than losing it. Advisory only: the artifact is well-defined, + so this never fails a build. + +- 675566f: Make the `os migrate` occupancy check actually fire, and extend it to + `files-to-references` (#3917 follow-up). + + The check shipped in #3924 relied on a SQL lock probe + (`PRAGMA locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE`), which is correct on a + WAL database — and blind on the journal mode the platform actually uses. + ObjectStack's sqlite driver runs `journal_mode = delete`, where an idle + connection holds no lock at all, so dogfooding against a real `os serve` + holding a real project database showed `os migrate apply` reporting the + database idle and running the migration unannounced: exactly the scenario the + check was added to prevent. The unit tests missed it because they built their + fixtures in WAL mode. + + The probe now leads with the signal that survives every journal mode: which + processes hold the file open (`/proc` on Linux, `lsof` on macOS). It also names + them — `is in use — it is open in pid 12367 (node)` — which is the actionable + part. The SQL probe is kept as a second signal for WAL databases on platforms + where process inspection is unavailable or the holder belongs to another user; + either signal firing counts as busy. + + `os migrate files-to-references --apply` now takes the same gate (and the same + `--force` escape hatch). It rewrites rows rather than schema, so a concurrent + writer on the same file is at least as dangerous there; a dry run only warns, + since its counts shift under a live writer but it writes nothing itself. + +- 9e01213: fix(cli,driver-sql): `os migrate plan` lists the datetime storage convergence (#3954) + + The datetime canonicalisation (#3912/#3942) added two steps to `initObjects`' + physical path: a row-rewriting backfill on SQLite and a `TIMESTAMP` → + `DATETIME(3)` column rebuild on MySQL. Both already respected the DDL deferral, + so `plan` performed neither and `apply` performed both — the behaviour was never + wrong. The reporting was. + + `PendingSchemaWork` could only express `create_table` / `add_columns`, so an + operator saw a plan listing two added columns, confirmed it, and `apply` + additionally rewrote every row of a datetime column — or took a metadata lock to + rebuild one on a large table. The plan promises to show what apply will do. + + - `PendingSchemaWork.kind` gains `normalize_datetime_storage` and + `widen_datetime_columns`, plus an optional `rows` carrying how much data the + step touches: row-writes for the backfill, the table's size for the rebuild — + the number that decides "now" versus "in a maintenance window". + - `previewDeferredSchemaWork()` measures both without performing either, reusing + the exact predicate each migration uses (the backfill's whole `WHERE`, the + widening's own `information_schema` filter) so the plan and the apply cannot + name different sets. A probe that cannot run is swallowed to "unlisted", never + to a failed plan. + - The CLI renders them under their own heading rather than folding them into the + additive section, whose "created when you apply" framing carries an implicit + promise that the work is never data-losing. `summarizePendingSchemaWork` — the + line read just before typing `y` — never omits in-place work. + +- 39eb01b: fix(runtime,cli,types): `os migrate` and the dev runtime now share one `__search` companion schema view (#3955) + + On a zh-locale deployment the dev runtime provisions the hidden `__search` + pinyin companion column (ADR-0098) on every eligible object, but the + `os migrate plan`/`apply` boot went through `createStandaloneStack`, which + never derived the locale-gated pinyin decision from the compiled artifact. + Its metadata therefore lacked every companion column, and `migrate plan` + reported each live `__search` column of a dev-created database as a + destructive orphan — with `--allow-destructive` as the printed remediation, + which would have dropped live feature columns. + + - `@objectstack/types`: new `collectConfiguredLocales(i18n)` and + `stampSearchPinyinEnabled(i18n)` — the single resolve-and-stamp helper for + `OS_SEARCH_PINYIN_ENABLED`. An explicit env value still wins; only a + positive locale-derived decision is stamped. + - `@objectstack/runtime`: `createStandaloneStack` stamps the decision from + the artifact's `i18n` before any plugin constructs a `SchemaRegistry`, and + surfaces `i18n` on its result like `requires`/`objects`/`manifest`. + - `@objectstack/cli`: the `serve`/`dev` boot now stamps through the same + shared helper (behaviour unchanged), so create/serve and plan/apply cannot + compute different schema views of the same source tree. + + A fresh CLI-created database is now also born with the same `__search` + columns the dev runtime would provision, instead of acquiring them on the + next dev boot. + +- 7733604: feat(spec,cli): publish the foundational capability slate so every runtime reads one list (#3786, cloud#925) + + `Serve.ALWAYS_ON_CAPABILITIES` — the capabilities auto-added to every app's + `requires` outside `--preset minimal` — was declared in the CLI, under a comment + noting that "cloud / multi-environment hosts (which live in a separate + distribution) mirror this list on their per-project kernels." + + Nothing made that true, and they had already diverged. Cloud's per-tenant slate + was missing **`sms`, `messaging` and `analytics`**, so an app that worked under + `objectstack serve` could lose `notify` deliveries and dataset previews once + hosted — silently, with no error anywhere. The framework's own comment on + `analytics` spells out the failure mode it was made always-on to prevent: + "Without it the dataset preview + dashboard/report analytics widgets silently + no-op." + + **New export: `PLATFORM_ALWAYS_ON_CAPABILITIES`** (`@objectstack/spec`, and + `@objectstack/spec/kernel`). The slate and its per-entry rationale now live + beside `PLATFORM_CAPABILITY_PROVIDERS` — the map published for exactly this + reason one release earlier, "so cloud's objectos-runtime and the framework CLI + classify a `requires` token identically". `Serve.ALWAYS_ON_CAPABILITIES` is now + a re-export of it, kept as a stable handle for existing callers rather than + deleted: one declaration, two readers. + + Four assertions make the single declaration trustworthy for both of them — the + slate is frozen, deduped and non-empty; its foundational prefix + (`queue, job, cache, settings, email, storage`) is pinned, because mount order + matters when services bind to each other during `kernel:ready`; every member is + a real `PLATFORM_CAPABILITY_TOKENS` entry; every member has a declared provider; + and every member is `edition: 'open'`, since a floor the open distribution + cannot mount is not a floor. Verified by mutation: an unknown token, an + enterprise-edition token, and a reordered prefix each turn the gate red. + + **No behaviour change.** The published slate is byte-identical to the list the + CLI already had, and `serve-defaults.test.ts` / `serve-capability-vocabulary.test.ts` + pass unchanged. What changes is that there is now something to derive from: + cloud's hosted runtime can drop its copy and read this instead, which is the + follow-up cloud#925 left open — it lands there once the `.objectstack-sha` pin + moves past this release. + +- 8db4587: fix(lint,cli): `os lint` / `os compile` 不再放行一个 `os validate` 会拒绝的 react 页面 + + `validateReactPageProps` 只手工接在 `os validate` 上,另外两个命令从来没跑过它。 + 在 showcase 的 react 页面上植入一处 gating 违规(`` + —— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测: + + ``` + os lint os compile os validate + 修复前 exit 0 放行 exit 0 放行 exit 1 拒绝 + 修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝 + ``` + + 这条规则在 #4340 之后已经是**整个 react 页面表面唯一**的字段解析闸门: + `` 的 columns/fields/sort/grouping/userFilters、`` 的 + fields/initialValues/sections/subforms、`record:*` 一族(与元数据表面共用同一张 + `COMPONENT_FIELD_SPECS`)、`` 的 aggregate/axes、以及 `searchableFields`。 + 漏接不是少几条警告 —— 而是这些绑定在 build 路径上**完全没人看**,包括其中会 gate 的那些。 + + 现接入 `REFERENCE_INTEGRITY_RULES`,`os validate` 里那处手工接线随之删除,三个命令的 + 答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是 + `validateReadonlyFlowWrites` 在 #4394 里刚走过的同一条路 —— 那次的教训是 + 「一张 map、两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、 + 一套命令集合」。 + + 规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就 + 传 `result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工 + 接一条规则时直接报错。 + + `validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites` + 的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。 + +- 7fec5d6: fix(lint,cli): `os lint` no longer passes a flow the other two commands refuse + + `validateReadonlyFlowWrites` was hand-wired into `os validate` and `os compile` + and never into `os lint`. Measured on the showcase app with one planted + violation — a `runAs:'user'` `update_record` writing a static-`readonly` field: + + | | `os lint` | `os validate` | + | ------ | ------------------- | ---------------- | + | before | **exit 0 — passed** | exit 1 — refused | + | after | exit 1 — refused | exit 1 — refused | + + That rule **gates** (a static `readonly` + literal field is a certain no-op: + the engine strips it from the UPDATE payload while the step still reports + success, #2948/#3425), so the divergence was not a missing warning — `os lint` + green-lit a build `os validate` stops. + + It now joins `REFERENCE_INTEGRITY_RULES`, and both hand-wired call sites are + deleted with it, so the three commands share one answer by construction rather + than by three people remembering. This is the drift the suite was created to end + (#3583 §5 D5) and which its own header cited this rule as the standing proof of. + + Two things made the wiring indefensible rather than merely untidy: + + - `validateFlowNodeWrites` (#4369) walks the **same** `config.fields` map to ask + the other half of the question — "does this field exist?" against "is it + writable?" — and is already a suite member. One map, two checks, two different + command sets. + - The two hand-wired sites did not even agree with each other on their input: + `validate` passed the PRE-parse `normalized` stack, `compile` the POST-parse + `result.data`. Verified equivalent for this rule before collapsing them onto + the suite's post-parse input, so no finding is lost. + + No rule behaviour changes: same ids, same severities, same messages. + +- 8dcc0f5: feat(runtime)!: retire the inert `DriverPluginOptions` — `DriverPlugin` takes `(driver, driverName?)` (#4320) + + `new DriverPlugin(driver, { datasourceName, registerAsDefault })` never did + what it promised: both options configured a datasource-registration block in + `start()` gated on `metadata.addDatasource`, a method **no metadata service + implements** — so the block early-returned on every boot since inception and + the options were dead weight (found while typing service lookups for #4251). + + **Migration** — delete the options argument; nothing changes at runtime + because nothing ever happened: + + - FROM `new DriverPlugin(driver, { datasourceName: 'x', registerAsDefault: false })` + TO `new DriverPlugin(driver)` + - FROM `new DriverPlugin(driver, 'name', options)` TO `new DriverPlugin(driver, 'name')` + - The string second argument (`new DriverPlugin(driver, 'memory')`) is unchanged. + + If you passed `datasourceName` expecting routing to a named auxiliary driver: + that routing never came from the option. It keys off the **driver name** — + `DriverPlugin.init()` registers `driver.`, ObjectQL's discovery loop + adopts it, and the engine's lifecycle/datasource resolution looks the name up + (see the telemetry provision in `os serve` for the pattern: stamp + `driver.name`, register the plugin, done). For Setup → Datasources visibility, + declare the datasource through `DatasourceConnectionService` / + `registerInMemory('datasource', …)` (ADR-0062). + + The `DriverPluginOptions` interface was module-local (never exported from the + package root), so the only public break is the constructor's second/third + argument shape. + +- 7309c81: chore(cli,create-objectstack): scaffolds no longer name a driver (#4065) + + `os init` and the `create-objectstack` blank template both listed + `@objectstack/driver-memory` in the generated `dependencies`. It was the only + driver named, which read as an endorsement — "this is the driver your app runs + on" — when it is in fact the **last-resort rung** of the dev step-down (native + `better-sqlite3` → WASM SQLite → mingo). A new project's first impression of the + data layer should not be the engine that enforces no primary keys, no + uniqueness, no `NOT NULL` and no column types. + + It was also redundant: `@objectstack/runtime` already depends on `driver-sql`, + `driver-sqlite-wasm` and `driver-memory`, and every script in both scaffolds runs + through the CLI, which carries all four. Removing the line changes nothing a + generated project can do — `objectstack dev` still resolves SQLite by default, + and `OS_DATABASE_URL` still selects Postgres / MySQL / MongoDB. + + Docs updated to match: the "packages you depend on" table in _Your first project_ + no longer lists a driver row (it now says where drivers come from), and the + Memory Driver section of _Database Drivers_ documents the opt-in persistence + default, carries a migration callout for the old `'auto'` behaviour, and points + test authors at in-memory SQLite. That section also claimed "Data is lost when + the process exits", which was simply false while `'auto'` was the default — it + wrote a file into the working directory. + +- 627b188: fix(seed-loader): count reference fields dropped from rows that were still written + + The loader had two failure outcomes and only counted one. A record it cannot + write is counted in `errored`. But an unusable **reference value** (an object + where a natural key belongs, an array on a single-value field) is removed from + the record — never written as NULL, which would sever an existing link on + upsert replay — and the row is written **without it**. Nothing counted that. + + So a load that quietly severed N associations reported `totalErrored: 0`, and + every count-driven surface read clean. The CLI boot banner — the one seed signal + that survives `os dev`'s boot-quiet window and the default `warn` level — printed + `showcase 42 rows`, and the warn line said `0 dropped record(s)`: true, and + useless ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + + `SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` + now count it. It is deliberately **not** folded into `errored` — the row _was_ + written, so that would break the `inserted + updated + skipped` reconciliation + against `total`. The banner names it separately: + + ``` + ⚠ Seeds: showcase 42 ok / 3 lost links ⚠ + ``` + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected. + +- 7ac1995: **`os dev` / `os serve` stop swallowing every plugin boot-phase log line — the + boot-quiet window buffers instead of discarding (#4012).** + + `serve` blanks stdout while the kernel boots so the startup banner is readable, + and dropped what it intercepted. `ObjectLogger` routes `debug`/`info`/`warn` to + **stdout** — only `error`/`fatal` go to stderr — so that one line swallowed + every boot-phase `logger.warn` any plugin emits: the ADR-0110 D5 + `[action-governance]` inventory, the automation engine's binding warnings, + every degraded-boot notice. `os dev` spawns `serve` with inherited stdio, so a + single drain blinded both entrypoints at every log level, and it inverted the + flag's own promise — the default is `warn` precisely "so flow/hook execution + failures surface (ADR-0032)". Data-phase logging was unaffected, which is why + the hole survived: `--log-level debug` printed thousands of lines with none + from boot. + + - The intercepted bytes now land in a line-oriented, bounded `BootLogCapture` + that classifies each line against `ObjectLogger`'s pretty/text/json + renderings and retains only records at `warn` or above, so buffer size tracks + a boot's warnings rather than its chattiness. The startup chatter the window + exists to hide is still dropped. + - Retained records replay under the banner, beside the automation and seed + summaries that exist for exactly this reason — and on the two exits that + never reach the banner: `OS_MIGRATE_AND_EXIT` (a deploy pipeline must not + lose a degraded-boot warning) and serve's error path, where a boot that died + is when its warnings matter most. + - `--verbose` / `--log-level debug|info` no longer open the window at all. + Buffering a stream the operator explicitly asked to watch would be the flag + defeating itself. + + On `examples/app-todo`, `os serve` went from 25 lines with zero WARN among them + to surfacing five boot warnings, including the `[action-governance]` line + naming all eight unbound actions. This closes the loop the D5 inventory + changeset left open: the inventory was already emitted correctly and is now + visible on the platform's own dev loop. + +- 857a6cf: fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085) + + The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is + a development platform, so it has to start without one — but `os serve +objectstack.config.ts` died during boot whenever the artifact was absent: + + ``` + Loading objectstack.config.ts... + [StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT… + + ✗ Service 'manifest' is async - use await + ``` + + Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with + only its `dist/objectstack.json` moved aside), and on every freshly authored + project between `os init` and its first `os compile`. The message named neither + the missing artifact nor a fix, so it read as an internal kernel fault. + + Three separate faults, each of which alone was enough to refuse the boot: + + - **`serve` registered the config-derived `AppPlugin` before the stack's own + `plugins[]`.** Registration order _is_ the kernel's init/start order, and that + slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and + `DefaultDatasourcePlugin` (which connects the database the app seeds through). + The wrap is now **appended** to `plugins[]`, the same slot + `createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot + and artifact-boot share one plugin order. The artifact path never hit this, + which is exactly what made a plugin-**order** bug look artifact-related. + + - **`ctx.getService()` reported a never-registered service as "is async".** + `PluginLoader.getService` is an `async` method, so its return value is _always_ + a Promise and its internal "not found" rejection can never surface + synchronously — the kernel read the answer off that Promise and told every + caller to `await` a service that did not exist, while the `not found` branch + below it was unreachable. It now decides from the registry: absent ⇒ + `[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged + `Service 'x' is async - use await`. The same crash now reads + `[Kernel] Service 'manifest' not found`, which points at the layer that is + actually wrong. + + - **`MetadataPlugin` treated an absent `local-file` artifact as fatal.** + `createStandaloneStack` always points it at `dist/objectstack.json`, so a stack + with no app at all could not boot. A **missing** local artifact is now "nothing + compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so + a later `os compile` hydrates the running server. The tolerance is + ENOENT-only — a malformed or unreadable artifact stays fatal — and + `bootstrap: 'artifact-only'` (sealed runtime, where the artifact _is_ the + deployment) keeps failing loudly rather than silently serving an empty runtime. + + `[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted + at callers for whom "no artifact" is a healthy state; a present-but-unusable + artifact keeps the loud warning. + + Pinned by an e2e pair that drives the real `os serve` with **no `os compile` + anywhere**: an app defined only by `objectstack.config.ts` (asserting its object + is in the started plugin set, not merely that boot survived) and a bare + `export default {}` platform. The #4012 fixture drops the `os compile` this bug + had forced on it. + +- 1e38158: fix(cli,runtime): an artifact you NAMED and a boot input you don't have are different failures — say which (#4110 follow-up, #4131 step 1) + + Three corrections, all from the same principle: a platform may boot with no + application (#4085), and that says nothing about how a MISSING NAMED INPUT + should be read. + + - **A named-but-missing artifact boots empty and silently.** #4110 made an + absent artifact non-fatal all the way down — right for the conventional + `/dist/objectstack.json`, which is just "not compiled yet". But + `OS_ARTIFACT_PATH` / `{ artifactPath }` skip the existence check by design, so + the tolerance reached them too: `OS_ARTIFACT_PATH=/nope os serve` printed + "booting from artifact", reached `Server is ready`, and named the missing path + NOWHERE in its output (serve's boot-quiet window drops the loader's calm + line). `createDefaultHostConfig` — the boot with no config, where the artifact + IS the deployment — now rejects a named local artifact that does not exist, + naming both the path and which source named it. The loader keeps its + tolerance, so the config-boot path #4085 fixed is untouched. + + - **"Configuration file not found" never said where it looked.** The two things + that actually happen are a typo'd filename and the wrong working directory, + and the second is the common one. It now names the config path, the artifact + path, and that `OS_ARTIFACT_PATH` is unset — and still refuses rather than + inventing a zero-object platform, pointing at `objectstack start` for a boot + that is app-less on purpose. + + - **That refusal was being truncated.** `this.exit(1)` unwinds to oclif's + `process.exit`, which does not drain a piped stdout, so a diagnostic split + across several `console.log` calls loses its tail — measured: only the first + two lines of the new message survived a pipe, i.e. exactly the part that says + where to look went missing. Both of `serve`'s pre-flight refusals now emit one + write. Caught by the e2e added here, not by review. + + Also corrects the plugin-ordering claims in `createStandaloneStack` and in the + test that pinned them: the comment said the datasource plugin's array position + "MUST precede ObjectQLPlugin: its start() connects the default driver", and the + test asserted that index with the same rationale. The connect happens in + `init()`, and the kernel resolves order from the dependency graph — which hoists + ObjectQLPlugin ahead of the datasource plugin (measured: 6 slots earlier), the + reverse of what the slot reads as. The test now pins the declared dependency + that actually orders the two inits, which deleting the array position cannot + break and deleting the declaration does. #4131 tracks making the AppPlugin end + of that contract enforced rather than conventional. + +- c53aa53: File-backed SQLite now runs `journal_mode = WAL` (#3941). + + `SqlDriver.connect()` set `auto_vacuum` and left the journal mode alone, so + every ObjectStack SQLite database ran SQLite's built-in default — a rollback + journal. That is the worst mode for the shape this platform actually has, which + is **several processes on one file**: a dev server, `os migrate`, + `os meta resync`, a test run. Measured, on the same file: + + | | rollback journal | WAL | + | :--------------------------------------------- | :------------------------------------------------- | :---------------------------------------------------------------- | + | writer while another process holds a read open | `SQLITE_BUSY` — committing needs an exclusive lock | proceeds | + | idle attached connection visible to SQL | no — a lock lasts only as long as its transaction | yes (`locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` reports busy) | + + The second row is why the `os migrate` occupancy check had to inspect file + descriptors to see a live server at all (#3940): under a rollback journal there + was nothing in the database to see. That signal stays — it names the process, + which WAL's lock probe cannot — but the SQL probe is now authoritative for + databases ObjectStack created rather than a fallback that was blind in practice. + Concurrent _writers_ still serialize; SQLite allows one at a time in any mode. + + Journal mode is a persistent property of the file, so an existing database is + converted in place on the next connect (a header change — no rows are touched) + and stays converted. Two consequences to plan for: + + - `app.db-wal` / `app.db-shm` exist beside the database while a connection is + attached, and `app.db-wal` can hold committed transactions. A clean shutdown + checkpoints them away; a naive copy of `app.db` alone while a server runs does + not. Use `sqlite3 app.db ".backup …"`. + - **WAL does not work on network filesystems** (NFS/SMB). Opt out with + `OS_DATABASE_SQLITE_JOURNAL_MODE=delete`, or per datasource with + `sqliteJournalMode: 'delete'` in the driver config (which outranks the env + var). Either form _applies_ `delete`, so it also converts a database that + already adopted WAL back — skipping would have stranded it. + + Nothing here fails a boot, and nothing is assumed: `PRAGMA journal_mode = X` + answers with the mode actually in force rather than raising on refusal, so the + reply is read back; and because a filesystem can accept WAL and then fail the + first read _through_ it, the mode is proven with a read and rolled back to + `delete` if that fails — with a warning naming the file and the escape hatch. + `synchronous` is untouched, so durability is exactly what it was. `:memory:` + databases are left alone, as is `auto_vacuum = INCREMENTAL`, which keeps + reclaiming under WAL (ADR-0057). + + `os db clean` now counts `-wal` / `-shm` as part of the database when it measures + what a `VACUUM` reclaimed, so bytes that were sitting in the log do not read as a + reclaim of zero. + + `@objectstack/driver-sqlite-wasm` deliberately stays out of WAL. Its live + database is in the WASM heap and what reaches disk is a byte image it exports, so + nothing reads the database across processes and the pragma buys it nothing — + while still being a persistent header change in the operator's file. sql.js + _accepts_ the pragma (its VFS is memory-backed), so this had to be declared + rather than discovered. + + It also now parks a `-wal` left behind by an unclean native-driver exit rather + than loading the image beside it: wasm SQLite cannot read that log, and leaving + it next to a freshly rewritten image would let a later real SQLite replay frames + that no longer belong to it. The warning names the file it parked and how to + recover what was in it. + +- 6f23667: **`config.storage` is not a stack key, and an undeclared top-level key now says + so instead of vanishing (#4167).** + + `os serve` read `config.storage` and forwarded it to `StorageServicePlugin`. + It could almost never arrive: `ObjectStackDefinitionSchema` does not declare + `storage`, and is not `.strict()`, so `defineStack` — which every documented + authoring path and every compiled artifact goes through — strips the key before + `serve` runs. The one combination that reached the branch (a bare-object config + on the config-boot path) then carried the `driver`/`root` spelling the plugin + does not read either, so it did nothing there too. + + The result was one authoring key that worked on a single unreachable-in-practice + path and disappeared silently everywhere else. A host writing + `storage: { driver: 's3', … }` believed it had configured S3 and got local disk. + + - **`serve` no longer reads it.** `resolveStorageCapabilityArg` takes only the + env root; the production warning stops naming `config.storage` and names the + two channels that work — `OS_STORAGE_*` and Setup → Settings, the latter being + the one with proper credential handling. + - **The undeclared-key lint now covers the stack's own top-level keys.** New + `lintUnknownStackKeys(rawStack, stackSchema)`, wired into `defineStack`, + `os validate` and `os compile` beside the existing walker. `storage` gets a + prescriptive entry naming both channels and why a stack definition is the + wrong home for a credential — it would commit it to git and to any published + artifact. An ordinary misspelling still gets the edit-distance suggestion + (`datasource` → `datasources`). + - **`os migrate files-to-references` shares the resolver.** It built + `{ driver: 'local', root }` — the same dead keys — so its adapter used + `./storage` while the server writes under `.objectstack/data/uploads` since + #4096. That command reconciles what records claim against what storage holds, + so a disagreeing root reconciled against the wrong tree. + + **`onEnable` is exempt, and the exemption has one owner.** `onEnable` is a + function, so `ObjectStackDefinitionSchema` cannot declare it and + `dist/objectstack.json` cannot carry it — but it is not lost: `AppPlugin` calls + it off the authored bundle, and the artifact-boot path grafts it back (#4095). + "Not declared" and "dropped at load" are different claims, and this is the + surface where they come apart. New `STACK_RUNTIME_MEMBERS` in `@objectstack/spec` + names the members the runtime honours off the bundle; the lint treats them as + declared, and the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now **derived** from it + rather than restating it, so the list that decides what gets grafted and the + list that decides what the lint stays quiet about cannot drift. `onDisable` is + deliberately not on it — nothing calls it, so a value written there really does + go nowhere and the lint should say so. + + Additive: `lintUnknownAuthoringKeys` keeps its signature. The new pass is a + separate export rather than a fold into that walker for two reasons. The walker + iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope + level — no objects, no pages, nothing to iterate — walks clean; and the stack + schema has to be INJECTED, because `stack.zod.ts` imports the lint module and + importing back would close a cycle. A separate export keeps that requirement + visible: a call site either asks for the coverage or does not, and its absence + shows up in a diff. An optional parameter would be the same silent-loss shape + this rule family exists to report. It follows the walker's posture rule — only a + schema that STRIPS unknown keys is linted, so if the stack schema ever graduates + to `.strict()` the parse takes over and this goes quiet. + + Verified end to end: authoring `storage:` through `defineStack` warns at load, + and `os compile` reports it for configs that skip `defineStack`. + + Nothing is being taken away that worked. `storage` was never in the schema, is + not documented anywhere, and has no consumer in `objectstack-ai/cloud` (checked). + Whether the platform should eventually grow a real in-stack storage declaration + is a separate question — if so it should follow `datasources`, which solves + credentials by referencing `sys_secret` rather than inlining them, and that + deserves an ADR rather than a resurrected undeclared key. + +- efcd68c: **The storage adapter stops being rebuilt and re-pointed on every boot, and the + "files may be unreachable" warning stops firing at a healthy server (#4096).** + + Every `os dev` / `os serve` boot printed: + + ``` + WARN StorageServicePlugin: storage adapter swapped (LocalStorageAdapter → + LocalStorageAdapter). Existing files were NOT migrated and may be unreachable + through the new adapter. + ``` + + The warning was telling the truth. `serve` constructed the plugin with + `{ driver: 'local', root }` — and `StorageServicePluginOptions` declares + neither key. Both were dropped silently, so the plugin applied its own + `./storage` default, `OS_STORAGE_ROOT` changed nothing, and uploads landed in a + directory nobody named. The `storage` settings namespace then corrected the root + on its first read (its manifest default is `./.objectstack/data/uploads`), + genuinely moving the backing store — every boot, forever. + + Three fixes, because there were three defects: + + - **`serve` now passes options the plugin reads** — `{ adapter: 'local', +local: { rootDir } }`. `OS_STORAGE_ROOT` takes effect, and local uploads land + under `.objectstack/data/uploads` from the first byte instead of `./storage`. + Extracted as `resolveStorageCapabilityArg` so the option SHAPE is pinned by + tests: a mismatch like this type-checks fine and does nothing at runtime. + - **A swap is skipped when nothing changed.** The plugin records what the + running adapter points at and compares resolved configurations, instead of + rebuilding whenever the settings namespace held any value at all — which is + every boot once that namespace has persisted its own defaults. + - **The warning now means what it says.** It fires when the BACKING STORE moved + (kind change, different root, different bucket/region/endpoint), not merely + when the adapter object was replaced. A credential rotation swaps the adapter + so the new key takes effect and logs at info: same bucket, nothing stranded. + A swap from a caller that resolved no target still warns — ignorance must not + silence it. + + Path spellings are normalised, so the platform writing the same default two ways + (`./.objectstack/data/uploads` in the settings manifest, + `.objectstack/data/uploads` in the CLI) is no longer read as a migration between + a directory and itself. + + Verified on `examples/app-todo`: the boot-diagnostics block went from four + warnings to three, with the storage line gone and `./storage` no longer created. + 19 unit cases cover the target resolver and the swap/warn split (including the + refusals), 4 plugin-level cases pin what a boot does and says, and 7 pin the CLI + option shape. + + `config.storage` authored with the `driver`/`root` dialect is still forwarded + verbatim and still not read by the plugin — the same mismatch one layer up. + Correcting it means deciding whether the plugin accepts that dialect or the + config schema is wrong, so it is filed rather than papered over with a lenient + alias here (AGENTS.md Prime Directive #12). + +- 68dea0b: feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243) + + The deployment-level data-migration flag ledger (`sys_migration`, #3617) was + registered by `@objectstack/service-storage` as its first consumer. That was + deliberate while the file migration was the only consumer, but the ledger now + gates storage-independent behaviour too — `os migrate value-shapes` (#4235) + and the fresh-datastore attestation (#4215) — and a non-file migration had to + boot the whole storage plugin just so the kernel carried the table. Any kernel + assembled without storage silently had no ledger at all, which read exactly + like "migration not run" (both answer false) while actually meaning "ledger + not installed". + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the ledger exists with the + platform, independent of which optional services are composed. The + fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger + bookkeeping, and its old home justified itself as "the service that registers + `sys_migration`". Definition ownership is unchanged (`sys_migration` stays in + `@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the + flag helpers and readers are untouched. + + Consequences: + + - `@objectstack/service-storage` no longer contributes `sys_migration` to the + manifest and no longer performs the fresh-datastore attestation. An embedder + composing `StorageServicePlugin` on a hand-built kernel that relied on it + for the ledger must compose `PlatformObjectsPlugin` (the plugin every + supported assembly path already includes). + - The CLI's `buildDataMigrationPlugins()` no longer boots storage for every + gated migration — it registers `PlatformObjectsPlugin` always, and settings + - storage only for `os migrate files-to-references` (`{ storage: true }`), + the one migration that actually reconciles against the storage adapter. + +- 1ddf479: `os init` scaffolds now stamp `engines: { protocol: '^' }` into the + generated `objectstack.config.ts` (all three templates), so newly authored packages + participate in the ADR-0087 load-time protocol handshake instead of being admitted + under the "no-range" grandfathering warning. The bundled example apps (`app-todo`, + `app-crm`, `app-showcase`) now declare the same range. (#4097) +- 8186a70: feat(spec,cli): report the authored object/field keys that get silently dropped (#3786) + + `ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they + do not declare **parses clean and is stripped on the way to storage**. No error, + no warning — the author configured something and it simply is not there. That is + the ADR-0104 failure class the `FieldSchema` prune tombstone already describes in + prose, and #4120 found five live instances of it inside `@objectstack/spec` + itself: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had + been rendering in Studio for releases while saving nothing. + + **New rule — `lintUnknownAuthoringKeys` (advisory).** Every authored key an + object or field sets that its schema does not declare is now reported, naming the + path, the key, and what to do about it: + + ``` + defineStack: objects.crm_case.fields.owner.pii: 'pii' is not a declared field key, + so its value is dropped at load — the `dataQuality` governance family was pruned + in 2026-06 as dead in both layers — it enforced nothing. + defineStack: objects.crm_case.capabilities: 'capabilities' is not a declared object + key, so its value is dropped at load — did you mean 'enable'? + ``` + + Two guidance tables carry the difference between a **rename** (`formula` → + `expression`, `cascadeDelete` → `deleteBehavior`, `capabilities` → `enable`, …) + and a **retirement** with no successor (`pii`, `indexed`, `encrypted`, + `startingNumber`, …). A retirement deliberately suppresses the edit-distance + fallback: `pii` is three edits from `min`, and "did you mean min?" reads as real + advice while being nonsense. Plain typos still get the fallback (`requred` → + `required`). Every entry was found in the wild, and a test asserts each rename + target is a key the schema really declares — so the advice cannot rot into + pointers at keys that no longer exist. + + **It never rejects.** Making these two schemas strict is the destination — the + enforce side of ADR-0049, and the tier programme #4001 began on the flow and + permission schemas. But `object` and `field` are the two most-authored surfaces + in the protocol, so flipping them rejects metadata that parses today: a migration + event for every consumer, and one that deserves to be scheduled on evidence + rather than guessed at. This produces that evidence and costs nobody a migration. + + Wired into every layer that performs the discard, all **pre-parse** (the parse is + what eats the key, so after it there is nothing left to report): + + - **`defineStack`** — warns on the console, once per distinct path, in strict + _and_ non-strict mode, since the key is dropped either way. + - **`os validate`** — a non-blocking warning, and included in `--json` output + rather than computed and discarded. + - **`os build` / `os compile`** — the same non-blocking warning. `defineStack` + already covers configs authored through it; this catches the ones that skip it + (a plain object default-export, `strict: false`), which would otherwise emit an + artifact with the key quietly gone. + + Verified against the three first-party example apps (`app-todo`, `app-crm`, + `app-showcase`): all clean, no false positives. + + New exports from `@objectstack/spec` (root and `/data`): `lintUnknownAuthoringKeys`, + `formatUnknownAuthoringKey`, `FIELD_KEY_GUIDANCE`, `OBJECT_KEY_GUIDANCE`, and the + `UnknownAuthoringKeyFinding` / `AuthoringKeySurface` types. No authoring change is + required by this release: metadata that loaded before still loads, unchanged. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [3ec8186] +- Updated dependencies [698cbc2] +- Updated dependencies [b1863a5] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [e5e8b10] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [bb1ce2e] +- Updated dependencies [b4be309] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [7a55913] +- Updated dependencies [0f12193] +- Updated dependencies [7a55913] +- Updated dependencies [f5ab1c7] +- Updated dependencies [9b6fe7c] +- Updated dependencies [3abd233] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [a946efd] +- Updated dependencies [ea24593] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [fccec22] +- Updated dependencies [2af1988] +- Updated dependencies [b3a2318] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [cbc08eb] +- Updated dependencies [0c4f5b2] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [302e972] +- Updated dependencies [4580597] +- Updated dependencies [eb9230c] +- Updated dependencies [60110bb] +- Updated dependencies [29e5a0e] +- Updated dependencies [bec0f9a] +- Updated dependencies [6fd0786] +- Updated dependencies [7df7c64] +- Updated dependencies [fae74b5] +- Updated dependencies [545d931] +- Updated dependencies [a225ef5] +- Updated dependencies [7bf5349] +- Updated dependencies [366105c] +- Updated dependencies [c9d254a] +- Updated dependencies [42e3b01] +- Updated dependencies [c8124e5] +- Updated dependencies [9e8f04d] +- Updated dependencies [39eb01b] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [4dc14cc] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [9881074] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [f0d6594] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [385c4b0] +- Updated dependencies [38182ff] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [af5b96b] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [d4720ca] +- Updated dependencies [43ff598] +- Updated dependencies [e5a4d26] +- Updated dependencies [839982e] +- Updated dependencies [623e555] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [10575f3] +- Updated dependencies [9a4932a] +- Updated dependencies [71af9f5] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [74aa3f0] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [7d80695] +- Updated dependencies [ade7be4] +- Updated dependencies [507b92a] +- Updated dependencies [ac1cc8c] +- Updated dependencies [99b4392] +- Updated dependencies [99ffc04] +- Updated dependencies [974c6d4] +- Updated dependencies [7309c81] +- Updated dependencies [a8dcc37] +- Updated dependencies [495019b] +- Updated dependencies [20bc1ec] +- Updated dependencies [ac6c0be] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [9e01213] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [3fe0ff1] +- Updated dependencies [be7945a] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [9555b07] +- Updated dependencies [8db4587] +- Updated dependencies [7fec5d6] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [7967133] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [f1f40b4] +- Updated dependencies [4580597] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [c53aa53] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [efcd68c] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/lint@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/client@17.0.0-rc.1 + - @objectstack/service-storage@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/driver-memory@17.0.0-rc.1 + - @objectstack/driver-sql@17.0.0-rc.1 + - @objectstack/metadata@17.0.0-rc.1 + - @objectstack/plugin-sharing@17.0.0-rc.1 + - @objectstack/plugin-security@17.0.0-rc.1 + - @objectstack/rest@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/cloud-connection@17.0.0-rc.1 + - @objectstack/plugin-auth@17.0.0-rc.1 + - @objectstack/plugin-approvals@17.0.0-rc.1 + - @objectstack/plugin-webhooks@17.0.0-rc.1 + - @objectstack/service-messaging@17.0.0-rc.1 + - @objectstack/service-automation@17.0.0-rc.1 + - @objectstack/trigger-api@17.0.0-rc.1 + - @objectstack/service-analytics@17.0.0-rc.1 + - @objectstack/plugin-audit@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/driver-mongodb@17.0.0-rc.1 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.1 + - @objectstack/account@17.0.0-rc.1 + - @objectstack/console@17.0.0-rc.1 + - @objectstack/mcp@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/plugin-email@17.0.0-rc.1 + - @objectstack/plugin-hono-server@17.0.0-rc.1 + - @objectstack/plugin-pinyin-search@17.0.0-rc.1 + - @objectstack/plugin-reports@17.0.0-rc.1 + - @objectstack/service-cache@17.0.0-rc.1 + - @objectstack/service-datasource@17.0.0-rc.1 + - @objectstack/service-job@17.0.0-rc.1 + - @objectstack/service-package@17.0.0-rc.1 + - @objectstack/service-queue@17.0.0-rc.1 + - @objectstack/service-realtime@17.0.0-rc.1 + - @objectstack/service-settings@17.0.0-rc.1 + - @objectstack/service-sms@17.0.0-rc.1 + - @objectstack/setup@17.0.0-rc.1 + - @objectstack/trigger-record-change@17.0.0-rc.1 + - @objectstack/trigger-schedule@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + - @objectstack/verify@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 87532fb77c..f300cf4b54 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 9bc4d4d776..e0dd1ddad2 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,186 @@ # @objectstack/client-react +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [cbc08eb] +- Updated dependencies [0c4f5b2] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/client@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index fa66036621..78e0165adb 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 1ffcec3b78..0b99eebaaa 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,567 @@ # @objectstack/client +## 17.0.0-rc.1 + +### Major Changes + +- b09d8d9: refactor(data)!: `query.cursor` is removed — no driver ever implemented keyset pagination (#4286 step 4) + + `cursor` promised keyset pagination and nothing served it: the key was accepted + and ignored, so every page came back identical — a caller looping "until + `hasMore` is false" never terminated. It was Tier A of the #4286 inventory: a + shipped public producer (`QueryBuilder.cursor()`) minting a key no executor + read. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------- | :------------------------------------------------------------------------- | + | `cursor: { created_at: last.created_at }` | `where: { created_at: { $gt: last.created_at } }` + the matching `orderBy` | + | `QueryBuilder.cursor({...})` | `.where({ created_at: { $gt: ... } }).orderBy('created_at')` | + + The one-line fix: **delete the key and seek with `where` on your sort key** — + every driver already executes that, with canonicalised temporal comparands. + + Mechanics: `retiredKey()` tombstones on both declaration sites + (`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared + prescription), so authoring the key fails `tsc` and a query still carrying it + fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as + the protocol-17 semantic migration `query-cursor-retired` (request surface — + nothing stored to rewrite). The caller-built `Record` shape + would not survive a real keyset design anyway: a first-class cursor, if ever + built, will be a response-minted opaque token (the pattern the + metadata-revision / flow-run / notification list endpoints already use — those + `cursor` params are unrelated and unchanged). + +- b09d8d9: refactor(data)!: `query.distinct` is removed, and with it the mis-wired REST count suppression (#4286 step 4) + + `distinct` promised `SELECT DISTINCT` and no driver ever rendered it — but it + was **mis-wired rather than merely dead** (#4286 finding 2, the harsher + ADR-0078 class): its only observable effect platform-wide was that the REST + list path treated a distinct query as _not countable_, silently degrading + `total`/`hasMore` to a page-local estimate while still returning duplicate + rows. A caller — or a self-verifying agent — saw the response change and + concluded the flag worked. It had a shipped public producer + (`QueryBuilder.distinct()`). + + **FROM → TO** + + | Was | Now | + | :--------------------------------------- | :-------------------------------------------------------------------------------- | + | `distinct: true` for unique combinations | `groupBy: ['category']` | + | `distinct: true` + count | `aggregations: [{ function: 'count_distinct', field: 'category', alias: '...' }]` | + | one column's distinct values | the SQL/memory drivers' `distinct(object, field)` door (driver-level) | + + The one-line fix: **delete the key**; deduplicate with `groupBy` / + `count_distinct`. + + Mechanics: `retiredKey()` tombstones on both declaration sites + (`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared + prescription); `QueryBuilder.distinct()` is deleted; registered as the + protocol-17 semantic migration `query-distinct-retired`. **Observable REST + change (`@objectstack/metadata-protocol`):** the count-suppression branch is + deleted — a list request that used to carry `distinct` now gets a real + `total`/`hasMore` again (that restoration is the point, not a side effect). + The per-aggregation `distinct` flag (`AggregationNode.distinct`) is a + different, live member and is untouched. + +### Minor Changes + +- 195ad76: fix(actions)!: failures speak HTTP — business rejections are 400, success is a single wrap (#3962) + + **BREAKING (raw-HTTP callers of `POST /api/v1/actions/...` only).** The + 200-with-inner-envelope wire was never a designed contract: no ADR or doc ever + specified it, it originated as the route's catch block reusing + `deps.success()`, and `/actions` was the only route of 12 that double-wrapped. + #3962 classifies it as a bug. Five defects traced back to that one extra layer + (the console's green toast on failed actions, `redirectUrl` never firing, a + marketplace install reported as installed when it failed, the client-envelope + divergence #3927 papered over, and crashes invisible to monitoring). + + The contract now, identical to `/data`: + + | Outcome | HTTP | Body | + | :------------------------------------------------------------- | :-------------------: | :-------------------------------------------------------------------- | + | Ran, returned | **200** | `{success: true, data: }` — single wrap | + | Ran, rejected (business rule / validation) | **400** | `{success: false, error: {message, code, details: {code?, fields?}}}` | + | Never dispatched (unknown / denied / wrong type / unavailable) | 404 / 403 / 400 / 503 | unchanged (#3930/#3951) | + | Crashed (`TypeError`, driver class, sandbox timeout) | **500** | unchanged (#3951) | + + A validation rejection carries `details.code: 'VALIDATION_FAILED'` and + `details.fields[]` — the exact payload #3937 fought for, now on the same wire + shape `/data` has always used, which `@objectstack/client` normalizes to + `err.code` / `err.fields` (#3927). A rejected flow is a 400 with + `details.code: 'FLOW_FAILED'`. The crash-vs-rejection discriminator (#3951, + error `name`) now selects 400 vs 500. + + `client.actions.invoke` / `invokeGlobal` still never throw: they fold every + failure status into `{success: false, error}`, read the single wrap on + success, and keep a NARROW legacy heuristic so a current SDK talking to a + pre-#3962 server still folds the old double-wrapped 200s correctly. + + **Migration for raw-HTTP third parties:** branch on the HTTP status — a + non-2xx is the failure, `error.message` / `error.details` carry the detail; on + a 200, `data` is the handler's return value directly (one level less than + before). Callers using `@objectstack/client` need no change. + +- c2bbd97: fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913) + + **1 — the registration key and the lookup key disagreed.** Both writers + register an objectName-less action under the literal `'global'`: `AppPlugin` + (`action.object || 'global'`) and `ObjectQLPlugin.actionObjectKey`. The REST + route's fallback probed `'*'`, and `engine.executeAction` is an exact-string + `Map` lookup with no wildcard semantics — so the probe could only ever miss: + + ``` + Action 'log_call' on object '*' not found + ``` + + `POST /api/v1/actions/global/log_call` worked by **accident** (the path segment + happened to spell the registration key); `POST /api/v1/actions//log_call` never + worked at all, and neither did falling back from an object-scoped route to a + global handler. `'global'` is now the canonical key + (`GLOBAL_ACTION_OBJECT_KEY`), the probe order is + `[, 'global', '*']` for both the REST route and the MCP + `run_action` bridge (`actionHandlerObjectKeys` — one list, two surfaces), and a + single-segment path (`/actions//:action`) routes at `'global'` instead of + 400-ing. A handler registered directly under `'*'` still resolves; the doc + comments that called `'global'` a "wildcard" are corrected at every site. + + **2 — "no such action" was reported as a success.** The not-found exit called + `deps.success(...)`, which always emits `{status: 200, body: {success: true, +data}}`, so a request naming an action that does not exist came back as: + + ```json + { + "success": true, + "data": { + "success": false, + "error": "Action 'log_call' on object '*' not found" + } + } + ``` + + Every caller that did not hand-unwrap the INNER envelope read the outer + `success: true` and reported a success that never happened — including the + shipped console, which showed a green toast (fixed on that side in + objectui#2963). Nothing **dispatched** there, so it is a **404** now, joining + the answers this route already gives a status: 403 denied, 400 wrong action + type, 503 unavailable. The miss also names the **routed** object rather than + whichever probe ran last (the old fallback said `on object '*'`, an object the + caller never asked for). + + A handler that **ran and rejected** is unchanged: HTTP 200 with + `data: {success: false, error, code?, fields?}`. That is a business outcome, + not a transport error, and #3937 pins it. The line is "did a handler run" — + below it the payload, above it the status. + + `client.actions.invoke` / `invokeGlobal` still do **not** throw. `client.fetch` + throws on every non-2xx, so `invoke` now catches and folds a dispatch failure + into the same `{ success, data?, error? }` result with `error` as a plain + string — otherwise the routes that just gained a status would have started + propagating exceptions into callers that only ever checked `result.success`. + +- 0c4f5b2: `err.code` no longer falls back to the pre-#3842 parking spot (`error.details.code`). The "newer SDK, older server" pairing that read served is not a supported deployment (SDK and server ship as one fixed release group), and the ADR-0112 batch-1 rename changed the code values anyway — a code dug out of an old server's parking spot would match no branch written against the current catalog (#4007). `err.category` / `err.retryable` are now read from inside `error`, where `ApiErrorSchema` declares them; the old top-level read yielded `undefined` against every conformant server (#4006). + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cbc08eb: fix(client): normalize both server error envelopes so `err.code` / `err.fields` mean one thing (#3918 follow-up) + + Two envelopes are in play and they disagree about where the semantic code and + the per-field list live: + + ``` + @objectstack/rest, flat: + { error, code: 'VALIDATION_FAILED', fields: [...] } + + runtime dispatcher, wrapped: + { success: false, error: { message, code: 400, + details: { code: 'VALIDATION_FAILED', fields: [...] } } } + ``` + + `error.code` in the **wrapped** form is the HTTP status, not a semantic code. + The client read it straight through, so `err.code` was the **number 400** where + the flat envelope gave `'VALIDATION_FAILED'` — meaning the branch our own docs + teach, + + ```js + if (err.code === 'VALIDATION_FAILED') err.fields.forEach(…) + ``` + + never matched on a dispatcher-served surface, and the field list (put on the + wire for those routes by #3918) was unreachable at `err.details.error.details.fields`. + + Now normalized at the throw site: + + - **`err.code` is always the semantic string.** It is read from the flat + `code`, else the wrapped `error.details.code`, else a _string_ `error.code` — + a numeric value is never reported as a code. The HTTP status is on + `err.httpStatus`, where it always was. + - **`err.fields` is the per-field list** whenever the server sent one, from + either envelope. It is left **unset** (not `[]`) when there is none, so + `if (err.fields)` is a safe test for "this failure is field-anchored". + - **`err.details`** prefers a top-level `details` (unchanged), then the wrapped + envelope's own `details`, then the whole body. The flat envelope has no + top-level `details` and so keeps falling through to the whole body exactly as + before — only the wrapped shape changes, and only from "the entire response" + to the structured object it actually carries. + + **Behaviour change worth noting:** code that read `err.code` from a + dispatcher-served route previously got a number and now gets a string (or + `undefined` where the server sent no semantic code). Nothing in this repo did — + `err.httpStatus` was always the correct source for the status, and remains + untouched — but a consumer that branched on `err.code === 400` should move to + `err.httpStatus === 400`. + +- 03d26f7: fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842) + + `HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it + straight into the field `ApiErrorSchema` reserves for a semantic string, so + `error.code` came back as `400`/`403`/`503` — a number, duplicating the response + status and occupying the one slot a caller is meant to branch on. The real code + then had to go somewhere else, and did, three somewhere-elses: `details.code` + (auth gate, permission denial, anonymous deny), `details.type` + (project-membership gate), and `error.type` (`routeNotFound`). Four sites, three + parking spots, because the declared one was full. + + **FROM → TO on the wire.** A dispatcher error body + + ```json + { + "success": false, + "error": { + "message": "…", + "code": 403, + "details": { "code": "PERMISSION_DENIED" } + } + } + ``` + + is now + + ```json + { + "success": false, + "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } + } + ``` + + | Reading | Was | Now | + | ------------- | ---------------------------------------------------------- | ------------------------------------------------- | + | semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` | + | HTTP status | `error.code` | `error.httpStatus` (or the response status) | + | context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) | + + **One-line fix for a direct reader:** replace `body.error.details?.code ?? +body.error.type` with `body.error.code`, and `body.error.code` with + `body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient` + already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still + reads the old shape, so a client newer than its server is unaffected. + + Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`, + `ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, + `VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not + rename anything. Reconciling the repo's two code vocabularies is #3841, and this + leaves it exactly one map and one enum to sweep instead of four parking spots. + + A branch with no code of its own is served one derived from the status, via the + single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus` + in `@objectstack/spec/api` (`403` → `permission_denied`, `503` → + `service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code` + is required; drawing it from `StandardErrorCode` keeps a derived code a + catalogued one rather than an invented string. + + **Spec changes:** + + - `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is + `EnhancedApiErrorSchema.httpStatus`. Additive. + - `StandardErrorCode` gains `method_not_allowed` and `precondition_required`, + the two statuses the runtime returns that the enum could not name. Additive. + - **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string + spellings of HTTP statuses, for matching against the numeric `error.code`). It + is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' | +'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum + declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO + `DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read + `error.httpStatus`. TypeScript flags every call site. + - **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()` + (was `z.number().int()`), `error.type` is **removed** (folded into `code`), and + `error.httpStatus` / `error.details` are declared. This schema is what + legitimised the deviation — it declared the opposite of `ApiErrorSchema` for + the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO + `{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`. + + **Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s + `errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405. + `errorResponseBase` previously discarded a thrown error's `.code` outright — it + had nowhere to put it — so the two exits of one surface disagreed about what a + caller would see; they now agree. Every body on this surface is built by one + helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by + `error-envelope.conformance.test.ts`: each branch driven and parsed against the + schema imported from `packages/spec`, plus a source scan so a new branch cannot + quietly reintroduce a numeric `code` or a `type`-as-code sibling. + + This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be + deleted rather than updated once the dispatcher was fixed. + +- 239c3a3: fix(spec)!: the #3963 / #4052 / #4158 / #4196 / #4286 retirements land in protocol **17**, not a protocol 18 that this train cannot produce (#4350) + + Ten tombstone prescriptions told authors a key "was removed in `@objectstack/spec` **18**", + and — worse — the machine agreed with them: a whole `step18` chain step and two + `toMajor: 18` conversions were wired for a major the release train does not reach. + + **17 is what ships.** `latest` is 16.1.0 and `rc` is `17.0.0-rc.0` — 17.0.0 has never been + published. `.changeset/pre.json` records `@objectstack/spec` at initialVersion 16.1.0, and + changesets computes a pre-mode bump from the last _published_ version: 16.1.0 + `major` = + **17.0.0**, released as `17.0.0-rc.N`. `PROTOCOL_VERSION` is `'17.0.0'`, and + `protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 + either. The "18" came from counting up from the in-flight `17.0.0-rc.0` instead of from + 16.1.0. + + **The prose was the smaller half.** `composeMigrationChain(from, to = PROTOCOL_MAJOR)` + filters `m <= toMajor`, so a step keyed 18 was **unreachable**: `os migrate meta --from 16` + walked steps 11–17 and silently skipped 18. The same ceiling applies to `composeSpecChanges`, + so the generated `spec-changes.json`, `docs/protocol-upgrade-guide.md` and the `spec_changes` + MCP tool — the ADR-0087 D4 primary channel — carried **none** of these seven retirements: + `query.joins`, `query.windowFunctions` and `BatchOptions.validateOnly` appeared zero times in + the committed manifest, and the upgrade guide contained no "18" at all. Authors would have hit + the tombstones with no chain hop to run and no upgrade-guide row to read. + + What changed: + + - `step18` is folded into `step17` — its rationale, both `conversionIds` + (`stack-api-require-auth-removed`, `flow-node-wait-timeout-keys-removed`) and all six + semantic migrations move across, and `MIGRATIONS_BY_MAJOR[18]` is gone. Both conversions + become `toMajor: 17` (`migrations.test.ts` requires a conversion's `toMajor` to equal its + step's major), and `CONVERSIONS_BY_MAJOR[18]` merges into `[17]`. + - All 30 hand-written "18" references become "17": the ten tombstone prescriptions + (`query.zod.ts`, `flow.zod.ts`, `rest-server.zod.ts`, `stack.zod.ts`, `protocol.ts`), the + `query.test.ts` pin regex that was holding the wrong number in place, the internal comments, + the `liveness/query.json` + `liveness/README.md` notes, and the seven unconsumed changesets. + - The seven retirements are written into the v17 release notes and upgrade checklist, where + they had no entry at all — there is no `v18.mdx` for them to have landed in. + + No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, #4196 and #4286 + stays retired, on exactly the terms those changesets describe. What changes is that the + prescription now names the version that will actually carry it, and `os migrate meta` actually + applies the two stack conversions instead of stepping over them. + +- 7309c81: test(runtime,client,metadata): back the remaining suites with in-memory SQLite instead of the mingo driver (#4065) + + Ten test files used `InMemoryDriver` as a convenience backing store — somewhere + for rows to go while the suite proved something else (REST routing, datasource + auto-connect, the batch `$ref` contract, metadata history). They now run on + `SqliteWasmDriver` at `:memory:`, the same engine `@objectstack/verify`'s + `bootStack` already gives the dogfood gate: pure JS (no native build, CI-safe on + any runner) and real SQL semantics. + + The point is fidelity, not tidiness. Production runs SQL, and mingo differs from + it in ways that let a suite pass while the behaviour it stands for is broken. + Every failure this migration produced was a fixture defect the memory driver had + been absorbing: + + - **Tables were never created.** `driver.create()` on the memory driver is a + bare `table.push()` onto an auto-vivified array, so an object registered + _after_ `kernel.bootstrap()` — which misses the boot-time schema sync — looked + fine. On SQL the first write fails with `no such table`, which the REST error + mapper turns into a **404 `OBJECT_NOT_FOUND`**: a routing-shaped symptom for a + DDL-shaped cause. Four suites needed an explicit `syncObjectSchema`. + - **A missing object declaration read as working.** `notifications.hono.integration` + writes `sys_notification`, which `MessagingServicePlugin` does not declare — + it is a platform object, and that lean kernel never booted `platform-objects`. + Auto-vivification hid the omission entirely. The suite now registers the real + `SysNotification` rather than a hand-copied stand-in, so there is still exactly + one schema for it (Prime Directive #12). + - **`connect()` was optional.** The memory driver needs none; a SQL driver does. + + What deliberately did NOT move: `read-coercion-conformance` keeps its two-driver + matrix (proving a stored value reads back as its declared type on _both_ engines + is the entire point of that gate), and the suites whose subject IS the memory + driver or its wiring — `standalone-stack` (`memory://` scheme), + `sqlite-driver-fallback` (the dev step-down), the CLI's driver-label tests, and + driver-memory's own suite. + + `datasource-autoconnect` is in that second group as of #4083, which landed a + regression test there for exactly the memory-pool property this PR originally + proposed to migrate away from. Moving that file to SQLite would have left the + new test passing vacuously — a wasm-SQLite pool never writes `.objectstack/` at + all — so it stays on the memory driver and keeps guarding what it was written + to guard. + + No new coverage is claimed here: each suite asserts exactly what it asserted + before, against a more faithful store. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/client/package.json b/packages/client/package.json index 2a32634a36..465be696fa 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ffcb38af07..90dbc3cf04 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,442 @@ # @objectstack/cloud-connection +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- f985b3f: fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + + **`packages/core/src/contracts/` was a dead near-copy of the real contracts, + and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had + ZERO importers — no relative import, no subpath export, not a tsup entry; + core's barrel has re-exported the `@objectstack/spec/contracts` versions all + along ("Re-export contracts from @objectstack/spec for backward + compatibility"). But the shadow had already **diverged** from the live + contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew + `rawBody?`; the copy never did), so anyone who grepped their way into it read a + stale contract that nothing enforces — the exact both-humans-and-AI failure + mode behind the false `http.server` exemption (#4382). Deleting it is + zero-risk by construction: nothing could reach it. + + **`http.server` is the canonical slot name, and the ledger now says so.** + `ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated + `'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin + register both two lines apart; cloud's two server entrypoints do the same). + Canonical is the only name present on EVERY provider path: runtime's + `config.server` path registers no alias, so the three cloud-connection plugins + that read the alias alone (marketplace-proxy, runtime-config, + marketplace-install-local) found an empty slot there — a live miss, now fixed: + all readers go canonical-first with the alias as a fallback that dies with the + alias registrations. The registrations themselves are untouched this release; + both sites now carry the deprecation note. + + **`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle + escape, declared once. Four consumers were each declaring it locally + (cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); + those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` + return is deliberate and documented at the single declaration: the handle's + real type belongs to the framework, and naming it would give the contract a + framework dependency. Adapters are not required to expose it; consumers + feature-detect. + + **`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options + their implementation has always accepted.** `bulkRegister`'s contract options + dropped the `MetadataWriteOptions` half its implementation intersects in + (`notify` is destructured on the method's first line); `bulkUnregister` + declared no options at all while the manager takes them. Same shape as the + `IDataEngine` read-methods gap from B2: a caller typed to the contract could + not reach the channel without erasing the lookup. Both additive; no implementor + or caller breaks. + + Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup + typed while touched). + +- ac1cc8c: fix(cloud-connection): align the marketplace seed test's timeout with its sibling (#3785) + + `marketplace-install-local-state-machine-exempt.test.ts` failed under a + full-repo `pnpm test` at 30s, while passing every time the package ran alone. + + Both marketplace seed tests drive `MarketplaceInstallLocalPlugin`, whose + seeding path dynamically imports the real `@objectstack/runtime` (unmocked on + purpose, twice: `recordSeedSummary` and `mergeSeedDatasetsIntoKernel`). That + cold import costs seconds by itself and multiples of that under a fully + parallel turbo run, and it is charged to whichever test triggers it first. + + Its sibling `marketplace-install-local-seed-lookup.test.ts` was diagnosed as + exactly this — _"an import stall, not a hang"_ — and raised to 120s. This file + was left at 30s and kept flaking the same way. The budget is now aligned, with + the rationale stated locally rather than only in the sibling. + + The flaky set turns out to be exactly the intersection of "does not mock + `@objectstack/runtime`" and "actually drives seeding": + + | test | mocks runtime | drives seeding | budget | + | :--------------------- | :------------ | :-------------------------------- | :----------------- | + | `conflict`, `bundle` | no | **no** — never reaches the import | default | + | `reseed`, `heal` | **yes** | yes | default | + | `seed-lookup` | no | yes | 120s (already) | + | `state-machine-exempt` | no | yes | 120s (this change) | + + So the two tests #3785 recorded are the only two that can hit this, and no + other file needs the same treatment. A genuine hang still fails — later. + +- 627b188: fix(seed-loader): count reference fields dropped from rows that were still written + + The loader had two failure outcomes and only counted one. A record it cannot + write is counted in `errored`. But an unusable **reference value** (an object + where a natural key belongs, an array on a single-value field) is removed from + the record — never written as NULL, which would sever an existing link on + upsert replay — and the row is written **without it**. Nothing counted that. + + So a load that quietly severed N associations reported `totalErrored: 0`, and + every count-driven surface read clean. The CLI boot banner — the one seed signal + that survives `os dev`'s boot-quiet window and the default `warn` level — printed + `showcase 42 rows`, and the warn line said `0 dropped record(s)`: true, and + useless ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + + `SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` + now count it. It is deliberately **not** folded into `errored` — the row _was_ + written, so that would break the `inserted + updated + skipped` reconciliation + against `total`. The banner names it separately: + + ``` + ⚠ Seeds: showcase 42 ok / 3 lost links ⚠ + ``` + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected. + +- d92c72d: fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + + The three selectors from #4321 all key off the erasure and the lookup being in + ONE expression. Split them and every selector misses: + + ```ts + let ql: any; + try { + ql = ctx.getService("objectql"); + } catch { + /* optional */ + } + ``` + + Selector 1 needs the call inside the declarator (this declarator has no init), + selector 2 needs `as`, selector 3 needs a type argument. The contract is erased + exactly as in `const ql: any = ctx.getService(…)`. + + **Why this could not wait for the batches.** The baseline's monotonicity check + means a file that leaves the grandfather list can never be re-added. So every + batch converted more of this shape from "grandfathered" into "lint covers this + file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` + into that state. A ratchet that reports a cleaner number the more you sweep is + the #4342 failure wearing different clothes, and the fix only gets more + expensive per batch shipped. + + **It is a rule, not a fourth selector, and that is the whole finding.** esquery + can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell + which declaration the assigned identifier resolves to — so it would equally + flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in + runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the + identifier needs SCOPE analysis. That is cheap and needs no type information, so + this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it + is a rule, and the earlier "just one more selector" estimate was wrong. + + Verified against exactly that: the rule flags all 16 real sites and none of the + 8 correctly-typed lookalikes. + + **Scale.** The baseline goes 140 → **169 sites** with the file count unchanged + at 37: 29 sites were already inside grandfathered files and simply invisible. + 16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 + in files never listed, 1 the regex sweep had missed) and are typed here — + `runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, + `plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, + `runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was + added; the key set still only shrinks. + + Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, + `II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they + do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, + `EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of + those record something worth naming: `IHttpServer` has no `getRawApp()` (the + contract is framework-agnostic and the raw app is Hono's own handle), and + ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader + at all — the engine attaches them via `(this as any)` and publishes nothing, + while `getHookMetricsRecorder()` exists for exactly that question about the + metrics recorder. Declared rather than laundered through `any`, and filed. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [698cbc2] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [a225ef5] +- Updated dependencies [c9d254a] +- Updated dependencies [c8124e5] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f3eaffd547..4ae2254304 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 fc900591b4..dacd5f27d8 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/connector-mcp +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index dfed03662a..d95e4dc6fb 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 2e7392d5a3..7327016b33 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/connector-openapi +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index d572fbf609..958e50a739 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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", @@ -35,9 +35,9 @@ "integration", "api" ], - "files": [ - "dist", - "README.md", - "CHANGELOG.md" - ] + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ] } diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 2fb1b6cd12..ae7d2cde81 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/connector-rest +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 758d5d3435..09dac35a3b 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 41024ad11e..684947906d 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,215 @@ # @objectstack/connector-slack +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index c4f42dcc4c..32c404ad38 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 63bff32873..a77614a656 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,234 @@ # @objectstack/console +## 17.0.0-rc.1 + +### Minor Changes + +- 302e972: Console (objectui) refreshed to `7d9734d5e321`. Frontend changes in this range: + + - feat(core): say which column identity key won, out loud (#3104 PR3) (#3124) + - fix(detail): Attachments become a peer tab with a live count badge, and their copy is translated (objectstack#4358) (#3123) + - fix(console,app-shell): readable reassign hand-off + "System" label for svc:\* audit actors (objectstack#4365, objectstack#4366) (#3121) + - fix(fields): lookup multi-value hydration batches via $in and shows loading instead of the empty placeholder (#3108) (#3120) + - fix(list,grid,detail,tree,core): every column resolver reads one key (#3104 PR2) (#3122) + - fix(core,list): 列身份归一到 ingestion chokepoint — 一列一个身份 (#3104 PR1) (#3119) + - fix(detail): a related list has one sorting semantics instead of two (#3106) (#3113) + - feat(components,grid,list): a column-header sort orders the whole list, not the page you can see (#3106) (#3112) + - fix(data-objectstack): a string `$orderby` reaches the server as a sort, not a list of character indices (#3106) (#3109) + - fix(types,core): the `*Validation` five derive from spec 17, and the engine stops disagreeing with the server (#3103) (#3107) + - fix(app-shell): lookup-param helpText only renders when the param actually degraded to a raw-id input (#3094) (#3095) + - fix(form): numeric/boolean option values survive selection typed (#3090 PR3b) (#3100) + - fix(list,detail): sorting a lookup column stops ordering by an invisible key (#3096) (#3102) + - feat(flow-designer): the script node's form authors what the executor runs (framework#4278) (#3099) + - fix(form): declare the runtime field metadata slot, ban the spec FormField misimport (#3090 PR3a) (#3097) + - fix(console): LocalizationFetchProvider retries a transient /me/localization failure (#3098) + - fix(app-shell,i18n): drop the developer-voiced default form subtitle (#3093) + - fix(form): spec-vocabulary fields stop crashing the standalone form; every surface names the boundary (#3090) (#3092) + - fix(form): harden the spec↔runtime form-field chokepoint, derive SelectOption, complete FormFieldSchema (#3090) (#3091) + - fix(types,layout): navigation metadata stops losing the spec fields the renderer already honours (objectstack#4115) (#3088) + + objectui range: `bebaebd39ace...7d9734d5e321` + +- 4580597: Console (objectui) refreshed to `96ee72e85439`. Frontend changes in this range: + + - fix(console): render the redaction notice on the enveloped resolve body (objectstack#3983) (#2980) + - feat(sdui): guard the public contract against silent drift (#2979) + - fix(sdui): lazy public blocks reach a kind:'react' page scope; ReactRunner keeps its errors (#2976) + - fix(list,data): bridge every spec view operator onto the filter AST (#2901) (#2974) + - fix(errors): error-code branches survive the framework's ADR-0112 rename (objectstack#3841) (#2977) + - fix(fields): a select no longer wipes itself when its value outruns its options (#2968) (#2969) + - fix(approvals): decision outputs reach both decision surfaces (#2955) (#2961) + + objectui range: `e651c936870e...96ee72e85439` + +- eb9230c: Console (objectui) refreshed to `a136322f8723`. Frontend changes in this range: + + - fix(app-shell)!: a modal action is client-side only — drop the server fallthrough (objectstack#3959) (#2973) + - fix(app-shell)!: the server-action URL identifies an action by `name`, not `target` (ADR-0110 D1) (#2970) + - fix(form): a server rejection that names fields now marks those fields (#2966) + - fix(actions): one source for the /actions envelope rule, and redirectUrl finally works (#2967) + - fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923) (#2965) + - fix(detail): multi-value lookup is selectable in inline edit (#2957) + - fix(actions): a failed server action no longer reports as success (green toast) (#2963) + - fix(fields): the criteria builder stops calling an empty criteria "All records" (#2962) + - feat(report): carry a report's `order` into the dataset selection (framework#3916) (#2964) + - feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890) (#2948) + + objectui range: `4a4829d0ef39...a136322f8723` + + **Release-critical for v17.** The previous pin (`4a4829d0ef39`) predates the + ADR-0110 D1 client fix, so the console it builds still posts `action.target` + to `/api/v1/actions/:object/:action`. Against a v17 server — which resolves + the declaration by `name` and refuses an unresolvable one (D3) — every + target-bound script action would return 404 from the shipped console. The + lockstep the ADR called for is enforced by THIS pin, not by merging the + objectui PR, so v17 must not ship without this bump. + +- 29e5a0e: Console (objectui) refreshed to `bebaebd39ace`. Frontend changes in this range: + + - fix(console): marketplace read cloud errors seven different ways — two break on the conversion, two are broken today (cloud#944) (#3086) + - feat(console): settings validation errors render against the fields that caused them (objectstack#4224 follow-up) (#3083) + - fix(notifications): the config, position and action variant are read instead of forked or ignored (#3014 follow-up) (#3085) + - fix(data-objectstack,core): an object filter no longer depends on whether the query expands a lookup (#3084) + - fix(app-shell): a published configSchema can no longer delete a node's sibling-block editors (objectstack#4045) (#3082) + - fix(view,list,core): a view's filter no longer disappears, or arrives as a predicate on columns that don't exist (#3081) + - fix(console): read the SETTINGS_LOCKED key from `error.details`, tolerating both shapes (objectstack#4224) (#3079) + - fix(list,data-objectstack,types): exporting a searched list no longer downloads the unsearched superset (#3078) + - fix(types,app-shell): one ObjectPermission, and the preview stops hiding three of its fields (objectstack#4115) (#3077) + - fix(notifications): the spec `icon` is read instead of stored and ignored (#3014 follow-up) (#3076) + - fix(plugin-grid): bulk-action params render the shared form field widgets — lookup errors get Retry, sys_user params get the PeoplePicker (#3064, ADR-0059) (#3073) + - feat(app-shell): the console mounts the notification surfaces (#3014 follow-up) (#3075) + - fix(data-objectstack): a view's own filter no longer vanishes when the user adds one (#3072) + - feat(notifications): each spec displayType gets its own presentation (#3014) (#3071) + - fix(grid): evaluate a bulk action's `visible` per selected record (#3067) (#3070) + - feat(sdui): curate the page:_, element:_ and action:\* families into the public contract (#3069) + - fix(list,i18n): a 400 from the server no longer reads as "check your connection" (#3066) + - feat(page,element): declare inputs for the eight configurable page:_/element:_ blocks (#3065) + - fix(app-shell,plugin-grid,i18n): autonumber/readonly fields become match-only import targets so "update if the record number exists" works (#3061) + - fix(types): Page/App/Dashboard validate the spec's own fields instead of passing them through (objectstack#4115 group C) (#3063) + - fix(plugin-form,i18n): form edit saves send If-Match and surface 409 conflicts instead of silently overwriting (#3060) + - fix(console): 403 blamed on the network, ⌘K search capped at 8 objects, nav gating fields inert (#3044) + - fix(grid): a bulk delete / by-name action clears the row checkboxes too (#3056) (#3058) + - fix(types,detail): derive five spec-named symbols instead of forking them (objectstack#4115) (#3057) + - fix(grid): drop the `bulkEnabled` derivation — the spec key is a tombstone (#3002) (#3053) + - fix(permissions,console): retry a transient /me/permissions failure instead of stranding the app on its loading state (#3050) (#3052) + - fix(test-setup): stop shadowing ten real registrations, and declare page:header's inputs (#3051) + - fix(scripts): --check reports real divergence instead of calling all 46 components "modified" (#3049) + - fix(view): the chart view gets a label and an icon in the view switcher (#2916) (#3040) + - feat(form): SplitForm honours the spec's new `FormSection.pane` (#3041) + - fix(types,layout): nav item type 'component' joins NavigationItemType and its zod enum (#2918) (#3039) + - fix(registry): prefix every namespaced key exactly once, in every namespace (#3037) + - fix(scripts): shadcn-sync refuses to silently delete local edits, and compiles the package after it writes (#3035) + - fix(scripts): shadcn-sync rewrites the registry paths Shadcn actually serves, and refuses to write a file when it cannot (#3033) + - fix(grid,types): an object-declared bulk action runs over the selected records (#3002) (#3031) + - fix(form): a wizard with `allowSkip` no longer submits past the fields you skipped (#3030) + - fix(components): resizable is a diverged file, not a synced one — stop the sync from breaking the build, and finish the v4 migration in it (#3029) + - feat(studio): a page button created in Studio can be given an action (#2997) (#3028) + - feat(record): declare inputs for the seven configurable record:\* blocks, and curate six (#3027) + - feat(eslint): ban dynamic imports in test hooks, and convert the last 33 sites (#3026) + + objectui range: `96ee72e85439...bebaebd39ace` + +- bec0f9a: Console (objectui) backfill for `2cb8d78e24ad...c6cfdf1288b6` — the one refresh in + the v17 window that landed with no changeset. + + `scripts/bump-objectui.sh` emits a `@objectstack/console` changeset on every bump + precisely so a SHA move leaves a trace (see `docs/releases-maintenance.md`). One + bump in this window did not, so 25 commits — including two breaking ones — were + absent from the release history and from the curated v17 page. This entry records + them after the fact; it declares no new SHA move (`.objectui-sha` already points + past this range at `4a4829d0ef39`). + + Frontend changes in this range: + + - feat(react)!: trim dead device/preference delegates from useClientNotifications (objectstack#3612 companion) (#2862) + - feat(types)!: drop the ObjectStack/ObjectOS/ObjectQL/ObjectUI Capabilities re-exports (#2860) + - feat: gate detail/form edit & delete on the server's effective operation set (framework#3546) (#2832) + - feat(app-shell): approver values become record lookups (framework#3508) (#2834) + - feat(console): group tenancy posture affordances — org switcher as write context + org attribution (ADR-0105 Phase 1) (#2858) + - feat(console): i18n the system-settings hub (objectui#2851 P2) (#2859) + - fix(dashboard,charts): resolve `{current_user_id}` in widget filters (framework#3574) (#2857) + - fix(grid): validate email format in the import preview (objectstack#3566) (#2840) + - fix(fields): consistent image-field rendering + click-to-zoom (#2836) (#2837) + - fix(app-shell): stop the flow-node repeater from committing during render (#2838) (#2839) + + Plus 15 dependency bumps, three of them major for the Console's own build: + `maplibre-gl` 5→6, `chalk` 5→6, `jsdom` 29→30 (dev). + + objectui range: `2cb8d78e24ad...c6cfdf1288b6` + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 60110bb: Console (objectui) backfill for `96ee72e85439...bebaebd39ace` — the 27 fix + commits that refresh's changeset did not enumerate. + + `scripts/bump-objectui.sh` emits a `@objectstack/console` changeset on every + bump precisely so a SHA move leaves a trace (see `docs/releases-maintenance.md`), + and the `console-bebaebd39ace.md` entry it wrote covers only the tail of its own + range: the range holds **94** first-parent commits, the enumeration lists 40, + and its oldest entry is #3026. Everything that merged earlier inside the same + range went unrecorded — in the release history and in the curated v17 page. + This is the second instance of the failure `console-c6cfdf1288b6-backfill.md` + records; it declares no SHA move (`.objectui-sha` already points at + `7d9734d5e321`, past this range). + + The 27 are all `fix`, hence `patch`. Several are data-loss fixes an upgrading + Console user feels immediately: + + - fix(form): a tabbed/sectioned modal keeps every tab's values (#2959, #2153) (#2987) + - fix(form): a split form keeps BOTH panels' values (#2153) (#3012) + - fix(form): a defaultValues change no longer discards the field being filled (#2982) (#2991) + - fix(components): apply new form defaultValues in the commit that renders them (#3001) + - fix(plugin-form): block page unload while a modal/drawer form has unsaved input (#2998) + - fix(plugin-form): swapping recordId no longer leaves the previous record on screen (#3005) + - fix(plugin-form): a wizard that ends on a field-less review step can finish (#2986) + - fix(form): a tabbed/split form honours the form view's own `columns` (#3018) + - fix(console): a flow or action that failed under HTTP 200 stops reporting success (#2958) (#2995) + - fix(grid): a legacy string row action runs instead of green-toasting a no-op (#2960) (#2996) + - fix(spec-parity): render the six Tier-1 spec values right instead of silently wrong (#2941) (#2993) + - fix(spec-parity): the Tier-2 spec values render instead of validating into nothing (#2942) (#3008) + - fix(spec-parity): the Tier-3 spec values render instead of red-boxing (#2943) (#3011) + - fix(view,components): the spec→FilterBuilder operator table covers the whole view vocabulary (#2945) (#2989) + - fix(view): the spec→FilterBuilder map follows the four operators #2942 added (#3022) + - fix(charts): a spec `series[].type` draws, and a spec-shape `series` plots at all (#2945) (#3004) + - fix(charts): say so when rows carry no category key, instead of drawing an empty axis (#3007) + - fix(analytics): a missing analytics capability no longer renders as an empty KPI (objectstack#3891) (#2981) + - fix(chatbot): read the agent catalog in the declared envelope too (objectstack#4053) (#2992) + - fix(sdui): a react page keeps its state; a source that exports nothing fails loudly (#2984) + - fix(sdui): a kind:'html' page can use lazily-registered blocks, and recovers when one registers late (#2988) + - fix(sdui): stop the react page's "no adapter yet" fallback churning its provider context (#3000) + - fix(sdui): the curated contract lists record:line_items, the tag that actually resolves (#3006) + - fix(record): register the record:\* blocks under one key, prefixed once (#3023) + - fix(plugin-list,plugin-grid): drop undeliverable formats from the export menu (#2999) + - fix(components): a stacked resizable group gets a divider, not a 1px sliver (#3024) + - fix(components,app-shell): the last two `direction` props follow v4's rename to `orientation` (#3025) + + Also in the range and deliberately not listed here: the refactor/chore/test/ + build/ci PRs the bump script's fix/feat filter excludes by design — including + three breaking-flagged refactors already reflected in the spec-side work + (#2990 the `execute` alias deleted from the action runner, objectstack#3856; + #3003 action sub-vocabularies derived from spec, and #3020 authoring types + become input types, both objectstack#4074). + + objectui range: `96ee72e85439...bebaebd39ace` + +- 6fd0786: Console (objectui) refreshed to `e651c936870e`. Frontend changes in this range: + + - fix(app-shell): unwrap the declared response envelope on the datasource page and the api-action runner (objectstack#3843) (#2972) + - fix(actions): read objectstack#3962's single-wrapped /actions responses (#2971) + + objectui range: `a136322f8723...e651c936870e` + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index e6c686fd48..8c76e4c183 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 cf8f291c13..67fab921b1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,702 @@ # @objectstack/core +## 17.0.0-rc.1 + +### Minor Changes + +- 32ccb23: feat(spec,core,runtime)!: ADR-0112 batch 1 — one error-code vocabulary, SCREAMING_SNAKE, schema-enforced (#3841) + + Settles #3841 per ADR-0112: the top-level `error.code` vocabulary is + SCREAMING_SNAKE, in two tiers. + + - **`StandardErrorCode` members renamed in place** (`validation_error` → + `VALIDATION_ERROR`, all 53). Breaking for importers that branch on the old + lowercase members; the type name and member _meanings_ are unchanged. + - **New `ERROR_CODE_LEDGER`** (`@objectstack/spec/api`): service-specific codes + (`AUTH_REQUIRED`, `VALIDATION_FAILED`, `ATTACHMENT_DOWNLOAD_DENIED`, …) are + registered per owning package. `ErrorCode` = standard ∪ registered. + - **`ApiErrorSchema.code` is now `ErrorCode`**, not `z.string()` — an + unregistered code fails parse, so the envelope conformance suites assert + values, not just shape. + - **`FieldErrorSchema.code` widened to `z.string()`** (ADR-0112 D6): field-level + codes are a separate vocabulary the enum never described; #3977 owns its real + catalog. + - **Derived codes changed case on the wire**: `standardErrorCodeForHttpStatus` + now yields SCREAMING members (`permission_denied` → `PERMISSION_DENIED`, + `method_not_allowed` → `METHOD_NOT_ALLOWED`, …) — this map was #3842's + designated one-file sweep point for exactly this decision. + - **`ANONYMOUS_DENY_CODE` is `'UNAUTHENTICATED'`** (was `'unauthenticated'`) — + the promoted code on anonymous-denied requests and the REST `enforceAuth` + body change spelling with it. + + `error-catalog.mdx` and the error-handling guides are rewritten to the single + vocabulary; a spec test now locks the catalog page's headings to the enum so + they cannot drift apart again. Remaining lowercase emitters (cloud-connection, + plugin-auth envelope codes, metadata-protocol, …) are the batch-2 sweep. + +- 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777) + + A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a + lower bound and was silently wrong for an upper one: the dashboard date-range + filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a + `datetime` column every row created after 00:00 of the `to` day vanished from + the result — no error, the chart renders, the numbers are just smaller. The + default configuration hit it: the filter's default field is `created_at` + (a system-injected `Field.datetime`) and 7 of the 13 presets end "today". + + The translation is operator-sensitive and half-open, applied at every + comparison emitter: + + - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a + bare-day comparand on a `datetime` column compiles to `< next-day-midnight` + in the column's storage form; `$between [min, max]` with a bare-day max + decomposes to `>= min AND < next-day(max)`. Both the plain and the + legacy-repair (mixed-storage) column paths, both `where` spellings. + - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day` + instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day. + - The `/analytics/sql` rendering and the dataset preview evaluator apply the + same rule, so the echoed SQL and drafted numbers reproduce execution. + + `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`: + the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants, + `Date`s and impossible days are never widened). + + Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns + (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands + (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for + those). No authored metadata changes: a dashboard's existing + `{ $gte, $lte }` window now simply includes its final day. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- d13004a: feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + + `kernel.use()` registration order was never a contract — the kernel resolves + init/start order from the plugin dependency graph — but a plugin that needed a + service at init _when its provider is composed_ while also booting _without_ + the provider had no way to declare that. `AppPlugin` was the standing example: + it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing + (a hard dependency would break empty-env / metadata-only / mock-engine + kernels), and so its correctness rode on which array slot each caller put it + in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s + first cut; then #4085, disguised for months as "crashes when the artifact is + missing"). + + The kernel `Plugin` contract gains three additive fields, enforced by both + `ObjectKernel` and `LiteKernel` through one shared implementation + (`plugin-order.ts` — the previously duplicated topological sort is unified + there): + + - **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. + - **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. + - **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + + Plugins that declare nothing get the diagnosis too: a `getService` miss + during Phase 1 now appends which plugin was initializing and — when a + composed plugin declares the service — who provides it and how to declare the + ordering. The `Service '' not found` prefix and the factory-backed + `is async - use await` message are unchanged. + + First adopters: `AppPlugin` declares + `optionalDependencies: ['com.objectstack.engine.objectql']` + + `requiresServices: ['manifest']` (cleared on the empty-env no-op path), so + the #4085 composition — AppPlugin registered before the engine — now boots + correctly in every slot; `ObjectQLPlugin` declares + `providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and + `MetadataPlugin` declares `providesServices: ['metadata']`. + + Everything is additive — plugins that declare nothing keep their exact + ordering semantics; no existing declaration changes meaning. + +- e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two + type-blind filter backends (ADR-0053 D-A3 / #4191). + + `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data` + (re-exported from `@objectstack/core`): it reads the UTC instant a temporal + operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a + bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a + zone-naive one being UTC, per D-B2) — and returning `null` for everything + else, notably a bare wall clock, which denotes no instant. + + Both type-blind evaluators now use it to compare a `Date` against wire text, + which JS relational operators cannot do: `<` and friends coerce with hint + `number`, so the `Date` becomes its epoch and the string becomes `NaN`. + + - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped + every `Date`-valued row in 10 of the 16 shared conformance cases. The + post-image is the caller's raw write payload, so an SDK write of + `new Date()` hit this directly, and fail-closed turned it into a **denied + write**. + - `service-analytics`' preview evaluator diverged on the same 10 cases in + BOTH directions, because `String(new Date())` sorts after every `'2026-…'` + comparand — a drafted chart both lost rows and gained ones, then changed + its numbers at publish. Rows from a mongo-backed dataset arrive as BSON + `Date`s, so this was reachable in normal use. + + Comparisons that did not involve a `Date` are unchanged. + +### Patch Changes + +- 2af1988: fix(formula,spec,core): the RLS write-side `check` evaluator honours calendar-day upper bounds (ADR-0053 D-D) + + `@objectstack/formula`'s `matchesFilterCondition` — the evaluator behind RLS + write-side `check` policies (ADR-0058 D4) — compared a bare `YYYY-MM-DD` `$lte` + bound literally. On a `datetime` post-image that meant a policy of the shape + `{ signed_on: { $lte: '{today}' } }` **denied every write made after 00:00**: + the write-side twin of the read-side data loss #3777 fixed, and the last of the + platform's filter backends that disagreed about what a bare day means as a + bound. + + `$lte` and a `$between` max now evaluate half-open against the next calendar + day, matching the SQL compiler, the memory and mongo drivers, and the analytics + preview evaluator. Unchanged, per the same semantics table: full-ISO bounds keep + exact-instant semantics, `$gte`/`$gt`/`$lt` keep their midnight anchoring, and a + plain `YYYY-MM-DD` value compares identically (string ordering makes the two + forms equivalent). The evaluator stays fail-closed on a null bound. + + **Where the rule now lives.** `nextUtcCalendarDay` moved from + `@objectstack/core` to `@objectstack/spec/data` — beside `date-macros.zod.ts`, + whose vocabulary it interprets. `formula` cannot depend on `core`, and a second + copy of the rule is exactly the divergence #3777 catalogued; `spec` is the one + package all six consumers already depend on, so this adds no dependency edge. + + No import changes are required: `@objectstack/core` re-exports the symbol, so + existing `import { nextUtcCalendarDay } from '@objectstack/core'` keeps working. + New code should prefer `@objectstack/spec/data`. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 45dc446: Every in-memory fallback and dev stub now self-describes with the standard `__serviceInfo` descriptor, classified by what it actually is (#4058 step 1). + + ADR-0076 D12 gave services one way to say "I am not the real thing", but the producers never converged on it: + + - The kernel's own fallbacks (`createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`) carried `_fallback: true` — a marker **no** consumer recognized, `readServiceSelfInfo` included — so both discovery builders reported them as fully `available`. + - `plugin-dev` marked all of its implementations with the same `_dev: true`, normalized to `status: 'stub', handlerReady: false`. That declared a working in-memory search index exactly as fake as an AI stub returning invented text. + + Both now carry `__serviceInfo`, split by a rule that holds across the whole set: + + - **`degraded`** — really does the work, with reduced capability: `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `metadata`, `workflow`, `realtime`. Its answers are true answers; the `message` names what is missing (no persistence, no scheduling timer, no state-machine validation, …). + - **`stub`** — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. Never to be mistaken for a capability. + + `handlerReady: false` is set independently wherever no HTTP handler serves the slot (`cache` / `queue` / `job` / `realtime`, and every `stub`). + + Discovery output changes accordingly — a kernel fallback that used to report `status: 'available'` now reports `degraded` with an explanatory message. No routing, gating, or dispatch behavior changes: every dispatcher domain still resolves services exactly as before. Consumers reading `discovery.services.*` get the truth instead of a uniform claim. + + For anything that duck-typed the old markers: `svc._fallback` / `svc._dev` → `readServiceSelfInfo(svc)` from `@objectstack/spec/api` (the legacy `_dev` key is still understood by that reader, so third-party stubs carrying it keep working). + +- f985b3f: fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + + **`packages/core/src/contracts/` was a dead near-copy of the real contracts, + and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had + ZERO importers — no relative import, no subpath export, not a tsup entry; + core's barrel has re-exported the `@objectstack/spec/contracts` versions all + along ("Re-export contracts from @objectstack/spec for backward + compatibility"). But the shadow had already **diverged** from the live + contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew + `rawBody?`; the copy never did), so anyone who grepped their way into it read a + stale contract that nothing enforces — the exact both-humans-and-AI failure + mode behind the false `http.server` exemption (#4382). Deleting it is + zero-risk by construction: nothing could reach it. + + **`http.server` is the canonical slot name, and the ledger now says so.** + `ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated + `'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin + register both two lines apart; cloud's two server entrypoints do the same). + Canonical is the only name present on EVERY provider path: runtime's + `config.server` path registers no alias, so the three cloud-connection plugins + that read the alias alone (marketplace-proxy, runtime-config, + marketplace-install-local) found an empty slot there — a live miss, now fixed: + all readers go canonical-first with the alias as a fallback that dies with the + alias registrations. The registrations themselves are untouched this release; + both sites now carry the deprecation note. + + **`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle + escape, declared once. Four consumers were each declaring it locally + (cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); + those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` + return is deliberate and documented at the single declaration: the handle's + real type belongs to the framework, and naming it would give the contract a + framework dependency. Adapters are not required to expose it; consumers + feature-detect. + + **`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options + their implementation has always accepted.** `bulkRegister`'s contract options + dropped the `MetadataWriteOptions` half its implementation intersects in + (`notify` is destructured on the method's first line); `bulkUnregister` + declared no options at all while the manager takes them. Same shape as the + `IDataEngine` read-methods gap from B2: a caller typed to the contract could + not reach the channel without erasing the lookup. Both additive; no implementor + or caller breaks. + + Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup + typed while touched). + +- 7777e8f: fix(spec)!: retire the never-built typed-event system; the lifecycle registry now lists the events that actually fire (#4212 follow-up) + + The lifecycle-event surface promised a typed-event system that was never + built, in three layers. `kernel/plugin-lifecycle-events.zod.ts` shipped ten + payload schemas (`PluginRegisteredEvent`, `PluginErrorEvent`, + `HookTriggeredEvent`, `KernelReadyEvent`, …) and a 21-name + `PluginLifecycleEventType` enum — zero consumers for every export, and the + enum was wrong in both directions: 17 names nothing fires, 10 real events + missing. `contracts/plugin-lifecycle-events.ts` declared the same 17 dead + names in `IPluginLifecycleEvents` next to 5 real ones, plus an + `ITypedEventEmitter` interface nothing implements. All of it read as a + promise; anyone who coded against it (hooking `plugin:started`, awaiting + `plugin:error`) registered a handler that could never fire, with no error + saying so — the same silent-drop shape as the #4212 lifecycle-hook family. + + Removed, with zero consumers verified repo-wide: + + - `kernel/plugin-lifecycle-events.zod.ts` and every export: `EventPhase`, + `PluginEventBase`, `PluginRegisteredEvent`, `PluginLifecyclePhaseEvent`, + `PluginErrorEvent`, `ServiceRegisteredEvent`, `ServiceUnregisteredEvent`, + `HookRegisteredEvent`, `HookTriggeredEvent`, `KernelEventBase`, + `KernelReadyEvent`, `KernelShutdownEvent`, `PluginLifecycleEventType` + (schemas and inferred types). + - `ITypedEventEmitter` from `contracts/plugin-lifecycle-events.ts`. + - The 17 never-fired names from `IPluginLifecycleEvents`. + + `IPluginLifecycleEvents` is now the registry of the **14 events with a real + emitter** — `kernel:{ready,bootstrapped,listening,shutdown}`, `app:seeded`, + `metadata:reloaded` (payload `metadata` now optional, matching the documented + contract), `external.schema.drift`, `ai:routes`, `auth:configure`, and the + `{service}:ready` convention family (`mcp`, `automation`, `analytics`, + `external-datasource`, `datasource-admin`) — each payload as observed at its + fire site. A new `LifecycleEventName` union types + `PluginContext.hook`/`trigger` in `@objectstack/core` as + `LifecycleEventName | (string & {})`: known names autocomplete, custom + cross-plugin names stay legal, existing callers compile unchanged. A pinning + test asserts two-way equality between the interface keys and the fire-site + inventory. + + FROM → TO: + + - `PluginLifecycleEventType` → `LifecycleEventName` (the union of names that + fire). There is no runtime enum; the bus is open by design. + - Event payload schemas (`KernelReadyEvent`, `PluginErrorEvent`, …) → the + payload tuples on `IPluginLifecycleEvents`. No wire format existed or + exists; payloads are in-process arguments. + - `ITypedEventEmitter` → `PluginContext.hook`/`trigger` (the emitter that + actually exists). + - Handlers for the 17 dead names → delete them; they never ran. For plugin + phase observation use the boot report (ADR-0084); for per-plugin errors the + kernel throws/logs at the failing phase. + + Plain deletion rather than `retiredKey()` tombstones, per the #4233 + precedent: these keys were never authorable — they described runtime event + payload records no config author can write, so the silent-strip class the + authorable-surface ratchet guards against is vacuous. Its baseline entries + and the `json-schema.manifest.json` keys are dropped deliberately in this PR. + No ADR-0087 conversion: no stack metadata names these types; there is nothing + for `os migrate meta` to rewrite. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- 857a6cf: fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085) + + The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is + a development platform, so it has to start without one — but `os serve +objectstack.config.ts` died during boot whenever the artifact was absent: + + ``` + Loading objectstack.config.ts... + [StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT… + + ✗ Service 'manifest' is async - use await + ``` + + Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with + only its `dist/objectstack.json` moved aside), and on every freshly authored + project between `os init` and its first `os compile`. The message named neither + the missing artifact nor a fix, so it read as an internal kernel fault. + + Three separate faults, each of which alone was enough to refuse the boot: + + - **`serve` registered the config-derived `AppPlugin` before the stack's own + `plugins[]`.** Registration order _is_ the kernel's init/start order, and that + slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and + `DefaultDatasourcePlugin` (which connects the database the app seeds through). + The wrap is now **appended** to `plugins[]`, the same slot + `createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot + and artifact-boot share one plugin order. The artifact path never hit this, + which is exactly what made a plugin-**order** bug look artifact-related. + + - **`ctx.getService()` reported a never-registered service as "is async".** + `PluginLoader.getService` is an `async` method, so its return value is _always_ + a Promise and its internal "not found" rejection can never surface + synchronously — the kernel read the answer off that Promise and told every + caller to `await` a service that did not exist, while the `not found` branch + below it was unreachable. It now decides from the registry: absent ⇒ + `[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged + `Service 'x' is async - use await`. The same crash now reads + `[Kernel] Service 'manifest' not found`, which points at the layer that is + actually wrong. + + - **`MetadataPlugin` treated an absent `local-file` artifact as fatal.** + `createStandaloneStack` always points it at `dist/objectstack.json`, so a stack + with no app at all could not boot. A **missing** local artifact is now "nothing + compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so + a later `os compile` hydrates the running server. The tolerance is + ENOENT-only — a malformed or unreadable artifact stays fatal — and + `bootstrap: 'artifact-only'` (sealed runtime, where the artifact _is_ the + deployment) keeps failing loudly rather than silently serving an empty runtime. + + `[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted + at callers for whom "no artifact" is a healthy state; a present-but-unusable + artifact keeps the loud warning. + + Pinned by an e2e pair that drives the real `os serve` with **no `os compile` + anywhere**: an app defined only by `objectstack.config.ts` (asserting its object + is in the started plugin set, not merely that boot survived) and a bare + `export default {}` platform. The #4012 fixture drops the `os compile` this bug + had forced on it. + +- d92c72d: fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + + The three selectors from #4321 all key off the erasure and the lookup being in + ONE expression. Split them and every selector misses: + + ```ts + let ql: any; + try { + ql = ctx.getService("objectql"); + } catch { + /* optional */ + } + ``` + + Selector 1 needs the call inside the declarator (this declarator has no init), + selector 2 needs `as`, selector 3 needs a type argument. The contract is erased + exactly as in `const ql: any = ctx.getService(…)`. + + **Why this could not wait for the batches.** The baseline's monotonicity check + means a file that leaves the grandfather list can never be re-added. So every + batch converted more of this shape from "grandfathered" into "lint covers this + file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` + into that state. A ratchet that reports a cleaner number the more you sweep is + the #4342 failure wearing different clothes, and the fix only gets more + expensive per batch shipped. + + **It is a rule, not a fourth selector, and that is the whole finding.** esquery + can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell + which declaration the assigned identifier resolves to — so it would equally + flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in + runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the + identifier needs SCOPE analysis. That is cheap and needs no type information, so + this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it + is a rule, and the earlier "just one more selector" estimate was wrong. + + Verified against exactly that: the rule flags all 16 real sites and none of the + 8 correctly-typed lookalikes. + + **Scale.** The baseline goes 140 → **169 sites** with the file count unchanged + at 37: 29 sites were already inside grandfathered files and simply invisible. + 16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 + in files never listed, 1 the regex sweep had missed) and are typed here — + `runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, + `plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, + `runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was + added; the key set still only shrinks. + + Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, + `II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they + do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, + `EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of + those record something worth naming: `IHttpServer` has no `getRawApp()` (the + contract is framework-agnostic and the raw app is Hono's own handle), and + ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader + at all — the engine attaches them via `(this as any)` and publishes nothing, + while `getHookMetricsRecorder()` exists for exactly that question about the + metrics recorder. Declared rather than laundered through `any`, and filed. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 18bc4b06a5..caf4f8aa7f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 8646908b63..3dd6f471dd 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,63 @@ # create-objectstack +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7309c81: chore(cli,create-objectstack): scaffolds no longer name a driver (#4065) + + `os init` and the `create-objectstack` blank template both listed + `@objectstack/driver-memory` in the generated `dependencies`. It was the only + driver named, which read as an endorsement — "this is the driver your app runs + on" — when it is in fact the **last-resort rung** of the dev step-down (native + `better-sqlite3` → WASM SQLite → mingo). A new project's first impression of the + data layer should not be the engine that enforces no primary keys, no + uniqueness, no `NOT NULL` and no column types. + + It was also redundant: `@objectstack/runtime` already depends on `driver-sql`, + `driver-sqlite-wasm` and `driver-memory`, and every script in both scaffolds runs + through the CLI, which carries all four. Removing the line changes nothing a + generated project can do — `objectstack dev` still resolves SQLite by default, + and `OS_DATABASE_URL` still selects Postgres / MySQL / MongoDB. + + Docs updated to match: the "packages you depend on" table in _Your first project_ + no longer lists a driver row (it now says where drivers come from), and the + Memory Driver section of _Database Drivers_ documents the opt-in persistence + default, carries a migration callout for the old `'auto'` behaviour, and points + test authors at in-memory SQLite. That section also claimed "Data is lost when + the process exits", which was simply false while `'auto'` was the default — it + wrote a file into the working directory. + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 5424485de9..be7fb72ed3 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index dccc1e8e0e..26f79db6a2 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,307 @@ # @objectstack/formula +## 17.0.0-rc.1 + +### Minor Changes + +- 4965bfa: Warn on flow-node `config` keys the node type does not declare (#4045). + + `FlowNodeSchema.config` is `z.record(z.unknown())`, so a misspelled or invented + config key was accepted in total silence: `visibleIf` instead of `visibleWhen` + registered cleanly, was never read, and the only symptom was a feature that quietly + did not happen. That diagnostic vacuum is what made #3528 take three passes and two + wrong diagnoses to resolve. + + `registerFlow` now compares each node's `config` against its descriptor's + `configSchema` and warns on anything undeclared, located and with the declared set + listed: + + ``` + [flow 'lead_conversion'] node 'screen_1' (screen): unknown config key `visibleIf` + at config.fields[0].visibleIf — It is not declared by this node type's + configSchema, so nothing reads it. Declared here: name, label, type, required, + visibleWhen. + ``` + + The walk descends where the schema declares structure and **stops at free-form + keyValue maps**, whose keys are author data (`filter: { status: 'stale' }`). + Descending matters: the #3528 typo class lives _inside_ the `screen` field + repeater, so a top-level-only comparison would miss the exact mistake this exists + to catch. + + **Warn, never reject.** An undeclared key is an author typo, a key the executor + genuinely reads that its hand-written `configSchema` never declared (`notify.source` + was exactly this), or dead config. Only 4 of the 13 schema-carrying builtins have + been audited for the second population, so hard-failing would gamble on the other + nine. Tightening to an error is a later, per-key decision once this warning has + measured the real distribution. Nothing about the published `configSchema` changes, + so no consumer sees a different shape. + + `@objectstack/formula` now exports `nearestName`, the edit-distance helper already + used for unknown-field and unknown-role suggestions, so "did you mean?" + diagnostics share one threshold. It is deliberately a bonus rather than the + mechanism — `visibleIf` → `visibleWhen` is distance 4 against a threshold of 3, so + the declared set is always listed instead of only as a fallback. + + Also fixes the first real finding from the new check: `showcase_inquiry_purge`'s + `get_record` node carried `mode: 'records'`, which no executor reads, with a comment + crediting it for behaviour that `limit > 1` actually produces. + +### Patch Changes + +- 2af1988: fix(formula,spec,core): the RLS write-side `check` evaluator honours calendar-day upper bounds (ADR-0053 D-D) + + `@objectstack/formula`'s `matchesFilterCondition` — the evaluator behind RLS + write-side `check` policies (ADR-0058 D4) — compared a bare `YYYY-MM-DD` `$lte` + bound literally. On a `datetime` post-image that meant a policy of the shape + `{ signed_on: { $lte: '{today}' } }` **denied every write made after 00:00**: + the write-side twin of the read-side data loss #3777 fixed, and the last of the + platform's filter backends that disagreed about what a bare day means as a + bound. + + `$lte` and a `$between` max now evaluate half-open against the next calendar + day, matching the SQL compiler, the memory and mongo drivers, and the analytics + preview evaluator. Unchanged, per the same semantics table: full-ISO bounds keep + exact-instant semantics, `$gte`/`$gt`/`$lt` keep their midnight anchoring, and a + plain `YYYY-MM-DD` value compares identically (string ordering makes the two + forms equivalent). The evaluator stays fail-closed on a null bound. + + **Where the rule now lives.** `nextUtcCalendarDay` moved from + `@objectstack/core` to `@objectstack/spec/data` — beside `date-macros.zod.ts`, + whose vocabulary it interprets. `formula` cannot depend on `core`, and a second + copy of the rule is exactly the divergence #3777 catalogued; `spec` is the one + package all six consumers already depend on, so this adds no dependency edge. + + No import changes are required: `@objectstack/core` re-exports the symbol, so + existing `import { nextUtcCalendarDay } from '@objectstack/core'` keeps working. + New code should prefer `@objectstack/spec/data`. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two + type-blind filter backends (ADR-0053 D-A3 / #4191). + + `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data` + (re-exported from `@objectstack/core`): it reads the UTC instant a temporal + operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a + bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a + zone-naive one being UTC, per D-B2) — and returning `null` for everything + else, notably a bare wall clock, which denotes no instant. + + Both type-blind evaluators now use it to compare a `Date` against wire text, + which JS relational operators cannot do: `<` and friends coerce with hint + `number`, so the `Date` becomes its epoch and the string becomes `NaN`. + + - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped + every `Date`-valued row in 10 of the 16 shared conformance cases. The + post-image is the caller's raw write payload, so an SDK write of + `new Date()` hit this directly, and fail-closed turned it into a **denied + write**. + - `service-analytics`' preview evaluator diverged on the same 10 cases in + BOTH directions, because `String(new Date())` sorts after every `'2026-…'` + comparand — a drafted chart both lost rows and gained ones, then changed + its numbers at publish. Rows from a mongo-backed dataset arrive as BSON + `Date`s, so this was reachable in normal use. + + Comparisons that did not involve a `Date` are unchanged. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 6fdc44c591..e78f7d210b 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ffcb84c519..458cf33134 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,1125 @@ # @objectstack/lint +## 17.0.0-rc.1 + +### Minor Changes + +- 6a67d7a: feat(lint): L2 action-body writes to undeclared fields warn at author time (#4271) + + The write-set lint that #4305 gave L2 hook bodies now covers the other surface + that carries one. An action body is the same artefact: the same + `HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in + `actionBodyRunnerFactory`, run in the same QuickJS sandbox. So it fails the + same way — `ctx.api.object('crm_deal').update({ stag: 'won' })` inside an + action reaches the driver unfiltered, and the outcome splits by driver: on SQL + the stray column fails the whole call with a driver-level error far from the + authoring site, and on a schemaless driver the stray key is persisted. Half + the surface was still blind. + + **New rule — `action-body-write-unknown-field` (advisory).** Wired into + `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` all + report it; it never blocks a build. Both places the runtime reads actions from + are walked — top-level `actions` and `objects[].actions` — and a + `defineStack`-merged action, which lives in both, is reported once at its + authored path. That dedupe is by VALUE (bound object + name + body source), not + by object identity the way `collectBundleActions` can afford: the suite runs on + the schema-PARSED stack, and parsing rebuilds every node, so the two copies + arrive as distinct objects that are merely equal. An identity check passes a + shared-reference unit fixture and then reports the showcase app's one warning + twice — which is exactly what it did before the end-to-end run caught it. + + **Only the `ctx.api` write family carries over, and that is the point.** An + action's `ctx.input` is its PARAMS bag (`input: unwrapProxyToPlain(actionCtx +?.params)`), not a record, so resolving those names against object fields would + flag every correctly-named parameter — a pure false-positive machine, and a + false positive kills an advisory lint. `ctx.record` is not a write surface + either: the runner hands the body a plain snapshot and never writes it back, so + `ctx.record.x = …` is discarded for _declared_ and undeclared fields alike — + a different defect from "the unknown column vanishes", and flagging only its + undeclared half would imply the declared half persists. + + So the rule ships a declared **partition** of the shared + `HOOK_BODY_WRITE_PATTERNS` rather than a second ledger: + `ACTION_BODY_WRITE_PATTERN_IDS` (today: `api-crud-literal`) and + `ACTION_BODY_WRITE_EXCLUSIONS` (`input-property-assign`, + `input-object-assign`), each exclusion carrying its reason. The two halves are + tested to cover the shared ledger exactly, so a fourth pattern landing on the + hook side fails this rule's test until someone classifies it — silence is not a + decision. Every applicable pattern is additionally proved end-to-end through + the full validator (prefilter, pattern filter and field check included), and + every exclusion is proved to be about applicability rather than an + unextractable shape: the shared extractor still sees it, and this rule still + reports nothing for it. + + One extractor, one field index, one implicit-field set, shared with the hook + rule rather than copied. The action rule is the same check on the other body + surface, so a second copy of `IMPLICIT_FIELDS` would drift exactly the way the + five hand-copied system-field lists #4330 collapsed did. + + The lint stays off the kernel boot path, and lands one notch tighter than the + hook side: the only applicable pattern is rooted at `ctx.api`, so an action + body that never mentions it does not even parse, let alone load the ~9 MB + TypeScript compiler. Guarded by `lazy-deps.test.ts`. + + `@objectstack/spec`: `ScriptBodySchema` and `ActionSchema.body` now point at + the action-side rule and spell out that `ctx.input` (params) and `ctx.record` + (a discarded snapshot) are not record-write surfaces — doc comments only, no + schema or generated-artifact change. + +- 0ecc656: feat(lint): an action body's discarded `ctx.record` write warns at author time (#4345) + + `#4344` deliberately left `ctx.record` alone, and said why: an action's + `ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that + `boundActionHandler` never writes back — the hook path's + `applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …` + is discarded for **declared and undeclared fields alike**. Reporting that + through the unknown-field rule would have been actively wrong: flagging only + the undeclared half implies the declared half persists, which is the false + completion this rule family exists to stop manufacturing. It needed its own + finding, and now has one. + + **New rule — `action-record-write-discarded` (advisory).** + + **It is not "flag every `ctx.record.` assignment"** — that would be a + false-positive machine, because mutating the snapshot to build a payload is a + legitimate idiom: + + ```js + ctx.record.stage = "won"; + await ctx.api.object("crm_deal").update(ctx.record); // the write is LIVE + ``` + + So the finding requires the write to be **provably dead**: reported only when + `ctx.record` never escapes the body as a value. Property reads + (`ctx.record.id`) do not rescue a write and do not suppress the finding; + handing the object to anything — an argument, an assignment RHS, a spread, a + return — does. Aliasing (`const r = ctx.record`) reads as an escape, which is + the safe direction: it costs a missed finding, never a false one. + + Truthiness and type tests are **not** escapes, and that distinction is what + makes the rule fire on real code rather than almost never. Running it against + the showcase app is what surfaced it: `mark_done` opens with + `ctx.recordId || (ctx.record && ctx.record.id)`, the defensive idiom action + bodies are actually written with, and counting that guard as an escape silenced + the finding on the one body in the repo that had a record write. A test reads + the reference and yields a boolean — or, for `&&`/`||`/`??`, yields the left + operand only when it is falsy, which is null or undefined and persists nothing. + Only the LEFT operand is a test: `x || ctx.record` really does evaluate to the + object, and still escapes. + + **One suite member, two rule ids.** Both findings fall out of one parse of one + source on one surface, so `validateActionBodyWrites` reports both rather than + `REFERENCE_INTEGRITY_RULES` growing a second member that would parse every + action body again to say two things about the same walk. The alternative — + hand-wiring it into the three CLI commands — is the drift that suite exists to + end, and `validateReadonlyFlowWrites` is the standing proof: wired into + `validate` and `compile`, never into `lint`. The trade-off is written down at + both ends rather than left to be rediscovered. + + **The ledger ratchet fired, as designed.** `record-property-assign` joins the + shared `HOOK_BODY_WRITE_PATTERNS` — the extractor's shape inventory, not any + one rule's — and both existing consumers had to classify it before it could + land. That was not cosmetic on the hook side: a `record-property-assign` write + carries no `object`, and `validateHookBodyWrites` branched on exactly that to + mean "a `ctx.input` write", so the new shape would have been reported as _"the + hook writes 'stage' to its input"_. The hook rule now declares its own + consumed subset (`HOOK_BODY_WRITE_PATTERN_IDS`) and its exclusion with a + reason — a hook sandbox context has no `ctx.record` at all + (`buildSandboxContext` never sets it), so the expression throws at run time + rather than silently no-op'ing, and a loud failure is not an advisory rule's + business. + + `extractHookBodyWriteSet` is the new one-parse entry point, returning the + writes plus the `ctxRecordEscapes` signal; `extractHookBodyWrites` stays as a + thin projection of it. + + **Boot path.** The action gate's prefilter widens from `api` to `api`-or- + `record`, so a body reaching neither still never loads the ~9 MB TypeScript + compiler. `lazy-deps.test.ts` pins it — and its header and two case names, + which still claimed every lazy dep waited on "a react page", now say which + trigger each one pins (typescript has also been loaded by the hook-body gate + since #4271). + + `@objectstack/spec` / `@objectstack/runtime`: `ScriptBodySchema`, + `ActionSchema.body` and `ScriptContext.record` now state that + `ctx.api.object(...)` is the only path that persists anything, and that + `ctx.record` is read-only in effect. Doc comments only — no schema or + generated-artifact change. Whether the runtime should instead refuse or honour + a record write stays open on #4345. + +- e4c61a7: Validate the expression slots a flow node's `configSchema` declares (#4027). + + A node type's designer `configSchema` and the keys its validators traverse were + two unreconciled lists. Both the engine's `registerFlow` pass and the author-time + `objectstack validate` pass hardcoded `config.condition` / `edge.condition` and + assumed every other node string was a `{var}` template — so a declared expression + property outside that hardcoded set was validated by nobody. + + That is how #3528 shipped. `screen.fields[].visibleWhen` has been on the `screen` + descriptor since #3304, typed `xExpression: 'expression'` (bare CEL) and offered + to authors in Studio, but no validator traversed it. An app authored the + predicate in the _other_ dialect — `'{createOpportunity} == true'` — and it passed + `tsc`, `objectstack validate` and registration in silence. Because `required` _is_ + enforced, a field the author had made conditional rendered unconditionally and + blocked Submit on an input the user was never shown: the run paused forever and no + resume was ever issued. + + Now: + + - **`FLOW_NODE_EXPRESSION_PATHS`** (`@objectstack/spec`) is the declared ledger of + expression-bearing node config paths, each recording the dialect it takes. + - **Both validators read it.** A malformed `visibleWhen` is a located, quoted + error at `registerFlow` _and_ at `objectstack validate` — `node 'screen_1' +(screen) screen field visibleWhen at config.fields[1].visibleWhen`. + - **A reconciliation ratchet** derives the expression properties from the live + descriptors and fails CI in both directions: a new `xExpression` property with + no ledger entry, or a stale entry no descriptor declares. It walks every + registered builtin, not just `screen`. + + Dialects are recorded rather than assumed because there are three, and two of them + disagree about braces: bare CEL (`{…}` is the #1491 brace-trap), single-brace + `{var}` flow interpolation (`{…}` is correct), and the ADR-0032 §3 double-brace + text template. Only bare-CEL slots are checked — `loop.collection` and + `map.collection` are recorded as `flow-template` and deliberately left alone, + since no validator implements their dialect and checking them under either of the + other two would reject every currently-valid flow. + + `ActionDescriptor.configSchema`'s TSDoc no longer claims `registerFlow()` + validates `config` against it. It never did: `FlowNodeSchema.config` is + `z.record(z.unknown())`, so types, `required`, `enum` and unknown keys are still + unenforced. The doc now states exactly what is checked and what is designer-facing + only, so nothing relies on a guard that does not exist. + +- cc60165: feat(lint): a flow `update_record` node writing an undeclared field gates the build (#4271) + + The write-set family #4305 (hooks) and #4344 (actions) opened had a third + surface, and it was the one the docs had spent the longest recommending as the + safe alternative to the other two. A flow `update_record` node whose + `config.fields` names a field the target object never declares was caught by + **nothing**: `validate-readonly-flow-writes.ts` walks that exact map and + explicitly stepped over the unknown key (`if (!meta) continue; // a +form/field-layout lint concern` — a referral to a rule that does not check + writes), and `validate-flow-template-paths.ts` checks the `{record.}` + READ tokens interpolated into node config, never the write-side key. So the + surface `hook-bodies.mdx` pointed authors at — "prefer a flow `update_record` + node, whose structural `fields` config is checked" — was the least checked of + the three. + + **New rule — `flow-node-write-unknown-field`, and it is an `error`.** Wired into + `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` report + it at once (one more place than the hand-wired readonly rule next door reaches). + + **Why it gates where its two siblings advise.** The hook and action rules are + advisory because they PARSE JavaScript: the finding is only as good as the + extractor, and a false positive kills an advisory lint. Nothing here is parsed — + `config.fields` is a literal map next to a literal `objectName`, the same + certainty `flow-update-readonly-field` already gates on one config key over. A + rule that errors on a write the engine _strips_ while only warning on a write + that names no column at all would be incoherent in the same `fields` map. + + And the runtime consequence is not the benign "consumer skips the unknown name + and renders the rest" that keeps `page-field-unknown` / `form-field-unknown` + advisory. Both halves were measured, not inferred: + + - Through the engine, an undeclared key reaches `driver.update` verbatim — the + flow executor calls the data engine directly, the UPDATE path strips only + readonly/readonlyWhen, and the SQL driver's `formatInput` / + `applyWriteColumnMap` pass an unrecognized key straight through (`m[k] ?? k`). + - On SQLite/knex it becomes `update "deal" set "name" = 'n2', "stagee" = 'won' … +→ no such column: stagee`. The statement is rejected **whole**: `name` — + spelled correctly, in the same payload — does not land either, and the step + fails with a driver error naming a column, far from the authoring mistake. + - On a schemaless datasource nothing rejects it, so the stray key is persisted + into a column the object never declares, where no schema-driven read returns + it. + + That is the call `validate-searchable-fields` makes for a stale entry and + `validate-flow-template-paths` makes for a filter-position token: gate when the + miss breaks or corrupts the operation, advise when it merely narrows the output. + + **One field index and one implicit-field set across all three surfaces.** + `indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule rather + than copied, so the three rules cannot drift on what is writable without being + authored — the shape #4330 collapsed one package over. + + Every skip exists so the gate only ever fires on a certainty, and each is + silent: a templated `objectName`, a non-literal `fields` map, an object this + stack does not define, an object that declares no fields at all (external / + datasource-introspected schemas, the same skip `validate-searchable-fields` + takes), and dotted keys (a nested-path write, not a top-level column). `runAs` + is deliberately NOT consulted, unlike the readonly rule that skips + `runAs:'system'` — an elevated identity bypasses the readonly strip, but no run + identity conjures a column. + + **Scope is declared as data, not left as silence.** `FLOW_WRITE_NODE_TYPES` + (today `update_record`) and `FLOW_WRITE_NODE_TYPES_DEFERRED` (`create_record`, + with its reason) are partition-tested against the CRUD node types that carry a + `fields` write map — derived behaviourally from the spec's executor-written + config schemas, not restated — so a node type that grows one later fails that + test until someone classifies it. + + `@objectstack/spec`: `ScriptBodySchema`'s "prefer a flow `update_record` node, + whose structural `fields` config is error-checked" note now names the rule that + makes it true. Doc comment only — no schema or generated-artifact change. + + Docs: #4355 had just rewritten `automation/hook-bodies.mdx` to record this gap + honestly — "**Prefer a flow `update_record` node when the write set is fixed — + but not for _this_ check** … writing a field the object never declares is + currently reported by nothing at all. On that one axis an L2 body is now the + better-checked surface." That bullet, and the matching note in + `automation/hooks.mdx`, are the two sentences this change makes false. Both now + say the axis has flipped back — and why the flow side lands a level _stronger_ + than the body side rather than merely level with it. + +- c1d44f7: feat(lint): L2 hook-body writes to undeclared fields warn at author time (#4271) + + An L2 (`language:'js'`) hook body that writes a field the target object never + declares — `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: … })` + — runs clean in the QuickJS sandbox and reaches the driver **unfiltered**: + `applyMutationsToInput` is a plain `Object.assign`, and the write-path + validator walks declared fields on insert and skips a key it has no field def + for on update. What happens next depends on the driver, and neither half is + acceptable: + + - **SQL** — the stray column enters the statement and the **whole write fails** + with a driver-level error (`table deal has no column named stagee`). The + write is lost, and the error surfaces far from the mistake that caused it. + - **Schemaless** (memory, MongoDB) — the driver spreads the payload, so the + stray key **is** persisted: an undeclared column nothing downstream reads. + + No diagnostic anywhere, and nothing at the authoring site either way — the + #4001 "the mistake is invisible where it is made" family. The read side + (`hook.condition`) and the capability surface were already statically checked; + the write side was the one blind face, and `hook-body.zod.ts` carried it as an + **accepted gap**. + + **New rule — `hook-body-write-unknown-field` (advisory).** `@objectstack/lint` + now parses each L2 body (TypeScript parser; parsed, never executed, never + type-checked) and resolves its literal writes against the target object's + declared + system fields. An unknown field warns with a did-you-mean. Wired + into `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` + all report it; it never blocks a build. + + The recognized write shapes are declared as data — `HOOK_BODY_WRITE_PATTERNS`, + each entry carrying a canonical example that a reconciliation test round-trips + through the real extractor, so a pattern cannot be declared-but-unverified + (#3528's death). v1 ships three: + + - `ctx.input. = …` / `ctx.input[''] ⟨op⟩= …` → the hook's own + target object(s); flat-input envelope keys (`id`/`options`/`ast`/`data`) are + never treated as record fields. + - `Object.assign(ctx.input, { : … })` → same target. + - `ctx.api.object('').insert|create|update({…})` / `.updateById(id, {…})` + → the named object, at the **real** `ObjectRepository` payload positions + (`update(data)` — the payload is argument 0, not `update(id, data)`). + + Everything statically unknowable is skipped silently, favouring missed findings + over false ones: computed keys, spreads, non-literal payloads, dynamic object + names, wildcard-target (`object:'*'`) input writes, cross-package targets, + aliased input (`const doc = ctx.input`), and multi-target hooks where the field + exists on _some_ target (the body may branch per object — only an + everywhere-miss warns). + + The lint stays off the kernel boot path: the TypeScript compiler loads lazily, + only when a hook actually carries a JS body (same contract as the react-page + gates, guarded by `lazy-deps.test.ts`). + + `@objectstack/spec`: the `ScriptBodySchema` header's "write-set opacity — + accepted static-analysis gap" note now points at the lint instead, and spells + out what remains opaque so the warning's absence is not read as proof of + correctness. + +- 3eb1b2b: feat(lint): every field-bearing prop on a React page block resolves against the + object it names + + #4329 closed ONE of them — `` — by running the + metadata rule's core from the gate that owns React block props. That prop was an + instance, not the class: every other prop a `kind:'react'` page binds BY FIELD + NAME shipped exactly as typed, the same silent drift `page-field-unknown` + already closes for the page-component `properties` bag one surface over. + + `validate-react-page-props` now resolves all of them: + + - `` `fields` / `columns` / `sort` / `grouping` / `userFilters` / + `hiddenFields` / `fieldOrder` / `filterableFields` + - `` `fields`, `initialValues` KEYS, `sections[].fields[]` + - `` / `` / `` / + `` — via the SAME `COMPONENT_FIELD_SPECS` table the + metadata surface uses, keyed by the block's `schemaType`, so the two surfaces + agree by construction rather than by two lists that happen to match + - `` — the escape hatch reaches the same table by the type the + author writes, so it is checked instead of being a hole + + Findings carry the metadata rule's id (`page-field-unknown`) at its advisory + severity, because the consumer behaves the same way: an unknown name is skipped + and the rest renders. + + **A FILTER position gates instead.** `` / `` name fields in a QUERY, and an unknown column there is not a skipped + column: the predicate can never match, `SqlDriver` swallows the driver's + "no such column" and returns `[]`, and the surface renders an empty list that + looks exactly like "there is no data" — the silent zero `filter-token-unknown` + and `validate-flow-template-paths`' filter-position call both gate on. Those + are reported as `error`. + + Filter positions are also resolved INDEPENDENTLY of each other, unlike every + other value this gate reads. `filters={['status', '=', stage]}` — a static field + beside a React-state value — is the shape a react page actually writes, and the + all-or-nothing static reader skipped the whole array, including the one position + that was knowable. + + Everything else is unchanged: a value from a variable, a call, or behind a + spread is unresolvable rather than wrong and is skipped silently (ADR-0072 D1), + as are cross-package objects, objects with no authored field map, dotted + relationship paths, and registry-injected system columns. + + ### Breaking: `` is the RELATED object, as the spec always said + + `RecordRelatedListProps.objectName` is the related (child) object — that is what + `record:related_list` means on every metadata surface, what + `validate-page-field-bindings` resolves its `columns` against, and what the one + registry component behind both surfaces consumes. The React overlay declared + `objectName` a SECOND time and glossed it "The parent object", and the generated + contract publishes the overlay's description in place of the schema's — so the + react surface both contradicted the spec and lost any way to name the object it + renders. + + FROM → TO for a page authored against the old gloss: + + ```diff + - + + + ``` + + `objectName` names the CHILD object being listed; the parent record stays bound + by `recordId`, and `relationshipField` is the child's field pointing back at it. + The lint above reports the old spelling (the child's columns and its FK do not + resolve against the parent). `objectName` is now also published as required, as + the schema declares it. + + The class is closed as well as the instance: `REACT_OVERLAY_SHADOWS` in + `@objectstack/spec/ui` ledgers every overlay prop that restates a spec-schema + prop, and a test asserts the ledger equals the real collision set — so the next + overlay entry that silently redefines a schema prop fails a test instead of + shipping a second dialect. + +- 9555b07: feat(lint): `` on a react page is checked against + the bound object's fields (#4329) + + #4328's `searchable-field-unknown` gates a stale `searchableFields` entry on + the metadata surfaces — an object's own ADR-0061 declaration, its built-in + named list views, and a `defineView` aggregate's default `list` / named + `listViews`. It did not cover the react page surface: `ListView` declares + `searchableFields` as a dataProp, so a `kind:'react'` page could write + `` and nothing resolved the + name. The failure is the one #4328 documents — the engine's + `resolveSearchFields` silently filters the stale name out, so the search scans + a narrower set than the page asked for, or (once every entry is stale) falls + through to the auto-default and scans a wider one; and once the REST read path + validates the `$searchFields` override (#4254), the prop objectui echoes + verbatim becomes a `400 INVALID_FIELD` on that list. + + The check lives in `validate-react-page-props` — the gate that already parses + the page's real JSX — and runs on `` usages whose `objectName` and + `searchableFields` are static literals, under the same rule id and severity + (`searchable-field-unknown`, `error`) as the metadata surfaces. It is not a + re-implementation: `validate-searchable-fields` now exports its core + (`indexObjectSearchTargets` + `checkSearchableFieldList`), and the react gate + runs that, so the two surfaces agree on what counts as a field by construction + — same three skips (an object this stack does not define, an object with no + authored field map, registry-injected system columns derived from the spec's + own declarations), same dotted-path strictness (search matches the field map + by exact string, so `owner_id.name` is flagged, not exempted). + + JSX-specific seams follow the gate's existing rules: a value that comes from a + variable, a call, or a spread is not knowable at build time and is skipped + silently — an unresolvable binding is not a wrong one (ADR-0072 D1). + +- 7967133: feat(lint): a `searchableFields` entry naming no field is caught at authoring + time, not at request time + + `searchableFields` is `z.array(z.string())` in both `object.zod.ts` and the + list-view schema, so nothing ever checked that an entry resolves to anything. + Rename a field and the old name stays behind — Zod-valid, shipped, pointing at + a column that no longer exists. + + The engine tolerates it, which is exactly what kept the drift invisible: + `resolveSearchFields` filters the declaration down to fields that exist + (`searchableFields?.filter((f) => all[f])`) and says nothing. The tolerance + fails in the direction nobody expects: + + - **some entries stale** → `$search` scans a NARROWER set than the object + declares. Records that should match do not, and the response is + indistinguishable from "no such record"; + - **every entry stale** → the filtered set is empty, so resolution falls + through to the AUTO-DEFAULT (name/title + short-text fields). A declaration + whose whole purpose is to CHOOSE the searchable set ends up selecting one the + author never wrote — the "asked narrower, answered wider" inversion #4226 + closed on the projection axis. + + It also stops being quiet downstream. Clients echo the declaration verbatim as + the `$searchFields` override (objectui's list search sends + `schema.searchableFields`), so once the REST read path validates that override + against the object (#4254), a stale entry the engine had been silently skipping + becomes a `400 INVALID_FIELD` on every list search for that object — a + request-time break whose cause is an authoring typo made long before. + + **New rule — `searchable-field-unknown` (gating).** Wired into + `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and + `os compile` with no CLI edit. It covers the object's own ADR-0061 declaration + and the list views that narrow it (`objects[].listViews`, a `defineView` + default `list`, and named `listViews`), resolving each entry against the bound + object's declared fields. + + `error`, not the advisory level the other field-existence rules use + (`page-field-unknown`, `form-field-unknown`, `semantic-role-field-unknown` are + all warnings). Those describe a consumer that SKIPS an unknown name and renders + the rest; this describes a declaration that either selects the wrong set or + refuses the request outright — the same call `validate-flow-template-paths` + makes for a filter-position token, where the miss widens the query instead of + shrinking the page. + + Existence only: a field that exists but is an odd search target (a `json` + column) is NOT flagged — an explicit `searchableFields` is authoritative, so + declaring one is a choice, not drift. Three skips keep false positives near zero + (ADR-0072 D1): an object this stack does not define, an object with no authored + field map (external / datasource-introspected), and registry-injected system + columns — the last derived from the spec's own `FIELD_GROUP_SYSTEM_FIELDS` and + `SystemFieldName` rather than hand-copied, since this package already carries + five slightly-different copies of that list. + + Dotted paths are the one place this rule is stricter than its siblings. They + skip `owner_id.name` because the query engine resolves the traversal; search + does not — `resolveSearchFields` matches the field map by exact string, so a + dotted entry is dropped exactly like a typo, and it is the spelling most likely + borrowed from `select`/`sort`. It is flagged, with its own fix hint. + +### Patch Changes + +- 78caf51: fix(lint): the write-set diagnostics describe what the runtime actually does (#4271) + + `hook-body-write-unknown-field` and `action-body-write-unknown-field` told + authors the undeclared column "silently never lands in the stored record". + Measured on `main`, that is wrong in **both** directions. Nothing between the + body and the driver filters the key — `applyMutationsToInput` is a plain + `Object.assign`, and `validateRecord` walks declared fields on insert and + `continue`s past a key with no field def on update — so the driver decides: + + - **SQL** — the stray column enters the statement and the **whole write + fails** with a driver-level error (`table deal has no column named stagee`). + Nothing is stored, so the correctly-spelled fields of that row are lost too, + and the error names a column far from the body that wrote it. + - **Schemaless** (memory, MongoDB — both spread the payload without consulting + the declared field set) — the stray key **is** persisted, as an undeclared + column nothing downstream reads. + + A lint that misdescribes the failure it is warning about teaches the wrong + debugging instinct: an author told the value silently vanishes will not connect + the driver error they actually see to the typo that caused it, and on a + schemaless driver will not go looking for the stray key that is really there. + All three messages now state the split, matching the "What still happens at + runtime" description #4355 gave `content/docs/automation/hook-bodies.mdx`. + + Both outcomes are pinned by a new integration test — + `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`. + Its insert cases run the full chain (real QuickJS sandbox, real hook body, real + engine, real driver against a real SQLite table), so "reaches the driver + unfiltered" is proved rather than asserted: if anything on that path ever + learns to filter, the SQL half stops throwing and the test goes red. The rule + headers, the `ScriptBodySchema` / `ActionSchema.body` notes and the two + still-unreleased #4271 changesets are corrected to match. #4355 fixed the + prose docs; this is the same correction on the surfaces that ship in the + packages — the diagnostic an author actually reads, and a test that pins it. + + `@objectstack/spec`: doc comments only — no schema or generated-artifact change. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 38182ff: feat(lint): `flow-node-write-unknown-field` covers `create_record` too (#4271) + + #4369 shipped the flow write-set gate on `update_record` alone and parked + `create_record` in `FLOW_WRITE_NODE_TYPES_DEFERRED` with its reason — a gating + rule earning its severity one measured surface at a time, recorded as data + rather than left as silence. This measures the other half and moves it across. + + **The INSERT path fails the same way, one notch harder.** Same literal + `config.fields` map, same `objectName` binding, same journey to the driver — the + engine hands an undeclared key to `driver.create` verbatim, alongside the audit + stamps. On SQLite/knex it becomes `table deal has no column named stagee` and + the statement is rejected whole, so the correctly named fields in the same + payload never land either. The extra harm is what does _not_ exist afterwards: + the row is never created, so every later node reading `{.id}` from that + node's `outputVariable` is working from a record that was never written. An + `update_record` failure at least leaves the record intact. + + So the message now names that consequence on `create_record` and only there — + "…and the record is never created at all" — instead of one sentence blurred to + fit both. + + Nothing else moves: same rule id, same `error` severity, the same silent bails + (templated `objectName`, non-literal `fields`, cross-package objects, objects + declaring no fields, dotted keys), and `runAs` is still not consulted. Each skip + is now pinned on the create surface as well as the update one, so the two node + types cannot drift into different behaviour. + + **`FLOW_WRITE_NODE_TYPES_DEFERRED` is now empty and deliberately kept.** The + partition test derives the full `fields`-write-map set behaviourally from the + spec's executor-written config schemas, so a node type that grows one later + belongs to neither list and fails that test until someone classifies it. + Deleting the empty array would turn that forced decision back into a default. + + Two non-members are now excluded on the shape of their failure rather than by + omission, both stated in the module header and one pinned by a test: + `get_record.fields` is a projection (`z.array(z.string())`) — a READ, where an + unknown entry narrows the selection instead of breaking the statement — and + `screen.defaults` is forwarded into the `ScreenSpec` the client renders, so an + unknown key is a prefill the renderer ignores. That inert "skips it and renders + the rest" case is exactly what this rule's `error` severity is defined against. + + Verified against the repo's own apps: app-crm, app-todo and app-showcase all + still validate clean with `create_record` covered — including crm's + convert-lead flow, which creates an account and an opportunity before updating + the lead. + +- af5b96b: fix(lint): flow rules see into try_catch / loop / parallel regions (#4380) + + Every lint rule that inspects flow nodes had hand-written the same one-liner — + + ```ts + const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; + ``` + + — and every one of them was therefore blind to the same thing. + `FlowRegionSchema` holds a full `nodes: z.array(FlowNodeSchema)`, and four + config slots carry one: `try_catch.config.try` / `.catch`, `loop.config.body`, + and `parallel.config.branches[].nodes`. Regions nest arbitrarily. Move a node + into any of them and the checking stayed behind. + + Measured before the fix, the same bad nodes at the top level vs inside a + `try_catch`: + + | rule | severity | flat | nested | + | :---------------------------------------------- | :------------ | :--- | :------------------ | + | `flow-node-write-unknown-field` | error | 1 | **0** | + | `flow-update-readonly-field` | error | 1 | **0** | + | `approval-approver-*` | error/warning | 1 | **0** | + | `flow-template-unknown-field` (filter position) | error | 1 | **1, as a warning** | + + **The last row is the one a reader would not predict.** + `validate-flow-template-paths` scans a node's whole `config` for string leaves, + so it still _saw_ tokens inside a region — but its `filter`-position split only + looks at the top level of the node it was handed. A nested filter token lost its + position, so the #3810 finding ("this node cannot run — an erased condition + WIDENS the query") silently degraded to an advisory warning, reported against + the wrapping `try_catch` instead of the `get_record` that is broken: + + ``` + FLAT error flow "f" node "get_record" flows[0].nodes[1] + NESTED warning flow "f" node "try_catch" flows[0].nodes[1] + ``` + + Being visible is not the same as being judged correctly. That is worse than a + clean miss: a yellow line reads as "checked and merely advisory". + + **One shared walk, not five.** `flow-walk.ts` — the flow-side counterpart of the + existing `page-walk.ts`, and here for the same stated reason: getting the + traversal right is subtle enough that duplicating it has already produced dead + rules. `walkFlowNodes(flow, flowPath)` yields every node with its real config + path (`flows[0].nodes[1].config.catch.nodes[0]`), a region breadcrumb for + diagnostics (`try_catch "Guard" › catch`), and depth. Four rules now route + through it: the two flow write rules, the template-path rule, and the approval + rule. + + Findings now land on the node that is actually wrong, which is the point — a + path pointing at the container is not actionable in a flow with several regions. + + **The double-count trap is handled, not left to each caller.** A container node + is walked too (it has its own config worth checking — a `loop`'s `collection`, a + `try_catch`'s `retry`), but its `config` physically contains every descendant, + so a rule that scans config recursively would report each nested finding twice. + `WalkedFlowNode.localConfig` is the container's config with region slots + removed; the recursive scanner uses it, and a test pins that a nested token is + reported once while the container's own `collection` token still is. + + `REGION_SLOTS` is declared as data and pinned against the spec's own + region-bearing config schemas — derived behaviourally (a slot is one that + accepts `{nodes: […]}`), not restated — so a fifth construct fails that test + instead of becoming a fifth silent blind spot. A `MAX_REGION_DEPTH` cap keeps a + hand-authored (pre-parse) stack from hanging a lint. + + Verified end to end: nested now matches flat on every rule, including the + restored `error` severity. app-showcase ships an `update_record` inside a + `catch` branch (`showcase_resilient_sync`) that had never been checked by + anything — it is correct, so validation stays clean, and breaking its field name + on purpose now fails `os validate` with + `flows[24].nodes[1].config.catch.nodes[0].config.fields.sync_statuss` and the + region trail `try_catch "Push with retry" › catch › node "Flag Sync Failure"`. + +- 7d80695: fix(lint): an object declaring no fields is unjudgeable, not "has no such field" (#4383) + + `hook-body-write-unknown-field` and `action-body-write-unknown-field` reported + **every** field write to an object that declares no `fields` — an external + object, or a datasource-introspected schema whose columns are resolved at + runtime. Measured before the fix: + + ``` + hook : ["hook-body-write-unknown-field / warning"] ← false + action: ["action-body-write-unknown-field / warning"] ← false + flow : [] ← correct + ``` + + `indexObjectFields` returns an **empty Set** for such an object rather than + `undefined`, and both rules only asked "is this object in the stack?" — + `targetSets.every((s) => s !== undefined)` and `if (!known) continue`. An empty + Set is neither undefined nor falsy, so it became the answer to `has(field)`, + and the answer is always `false`. + + That field map is not empty, it is **unknown**. The distinction already existed + in two other rules of the same family, each with its reason written down — + `validate-searchable-fields` skip #2 and `validate-flow-node-writes` (#4369, + which added the guard because it gates). Two of four had it; the drift shape + #3583 and #4330 exist to remove. + + **Fixed once, not twice.** The guard now lives in a shared + `judgeableFieldsOf(index, objectName)` that returns the declared names only when + they are a sound basis for a "resolves to nothing" judgement, and `undefined` + for both unjudgeable cases — cross-package objects and fields-less ones. All + three write-set rules route their lookups through it, so a fourth cannot repeat + the omission. It is internal to the family (not re-exported from the package + barrel), same as `indexObjectFields` and `IMPLICIT_FIELDS`. + + One semantic call worth naming: a **multi-target** hook where only _some_ + targets are judgeable is now skipped entirely. The `ctx.input` finding fires + only when a field is missing from EVERY target, and an unjudgeable target is one + the field might well exist on — so judging the remainder would assert "missing + everywhere" on evidence that does not cover everywhere. Consistent with the + rule's stated asymmetry: prefer a missed finding to a false one. + + No behaviour change for objects that declare fields: an unknown field on a + normal object still warns exactly as before, pinned by a test placed next to + each new skip so the guard cannot swallow the real finding. + +- ade7be4: fix(lint): the seven system-field exemption lists derive from the spec's declarations (#4330) + + Five rules in `@objectstack/lint` each carried their own hand-copy of + "registry-injected columns present on almost every object but absent from + authored `fields`" — and they had already drifted from one another (two more + copies had appeared by the time the fix landed). This is the shape #3786 + removed from the audit-provenance family, rebuilt one package over: the same + list, maintained in parallel, each under a comment asking to be kept in sync + with one of the others. + + The package now has one module, `system-fields.ts`, whose `SYSTEM_FIELDS` is + DERIVED from the spec's two declarations — `FIELD_GROUP_SYSTEM_FIELDS` + (`@objectstack/spec/data`) and `SystemFieldName` (`@objectstack/spec/system`) + — and all seven field-resolving rules consume it. A pin test holds the + boundary in both directions: the set contains exactly the two declarations' + union, and none of the rule-local exemptions. + + Two deliberate behavior consequences, both in the permissive direction the + rules' own comments argue for (over-inclusion costs at worst a missed + warning; under-inclusion costs a false one): + + - `widget-bindings`, `page-field-bindings` and `react-page-props` now also + exempt `is_deleted`; + - `flow-template-paths` now also exempts `user_id`. + + Names that are NOT system columns in the spec's sense (`name`, `owner`, + `record_type`, and the legacy physical spellings `_id` / `space`) stay + rule-local next to the reason each rule exempts them, instead of widening + every rule: `name` in particular is an ordinary authored field on most + objects, and exempting it package-wide would stop the field-existence rules + from catching a reference to a field the object genuinely does not have. + +- 8db4587: fix(lint,cli): `os lint` / `os compile` 不再放行一个 `os validate` 会拒绝的 react 页面 + + `validateReactPageProps` 只手工接在 `os validate` 上,另外两个命令从来没跑过它。 + 在 showcase 的 react 页面上植入一处 gating 违规(`` + —— 谓词命中不了任何行,列表回空,和「本来就没数据」无法区分)实测: + + ``` + os lint os compile os validate + 修复前 exit 0 放行 exit 0 放行 exit 1 拒绝 + 修复后 exit 1 拒绝 exit 1 拒绝 exit 1 拒绝 + ``` + + 这条规则在 #4340 之后已经是**整个 react 页面表面唯一**的字段解析闸门: + `` 的 columns/fields/sort/grouping/userFilters、`` 的 + fields/initialValues/sections/subforms、`record:*` 一族(与元数据表面共用同一张 + `COMPONENT_FIELD_SPECS`)、`` 的 aggregate/axes、以及 `searchableFields`。 + 漏接不是少几条警告 —— 而是这些绑定在 build 路径上**完全没人看**,包括其中会 gate 的那些。 + + 现接入 `REFERENCE_INTEGRITY_RULES`,`os validate` 里那处手工接线随之删除,三个命令的 + 答案由构造保证一致。这正是 suite 设立要终结的漂移(#3583 §5 D5),也是 + `validateReadonlyFlowWrites` 在 #4394 里刚走过的同一条路 —— 那次的教训是 + 「一张 map、两个检查、两套命令集合」,这次是「一次 JSX parse、七个 rule id、 + 一套命令集合」。 + + 规则行为零变化:id、严重级、文案都不动;喂进去的输入也不变(`os validate` 原本就 + 传 `result.data`,suite 拿到的是同一个)。`#4402` 的接线守卫会在下一次有人想再手工 + 接一条规则时直接报错。 + + `validateReactPageProps` 沿用 `validateHookBodyWrites` / `validateActionBodyWrites` + 的惰性约定:只有真的存在 `kind:'react'` 页面时才加载 TypeScript 编译器。 + +- 7fec5d6: fix(lint,cli): `os lint` no longer passes a flow the other two commands refuse + + `validateReadonlyFlowWrites` was hand-wired into `os validate` and `os compile` + and never into `os lint`. Measured on the showcase app with one planted + violation — a `runAs:'user'` `update_record` writing a static-`readonly` field: + + | | `os lint` | `os validate` | + | ------ | ------------------- | ---------------- | + | before | **exit 0 — passed** | exit 1 — refused | + | after | exit 1 — refused | exit 1 — refused | + + That rule **gates** (a static `readonly` + literal field is a certain no-op: + the engine strips it from the UPDATE payload while the step still reports + success, #2948/#3425), so the divergence was not a missing warning — `os lint` + green-lit a build `os validate` stops. + + It now joins `REFERENCE_INTEGRITY_RULES`, and both hand-wired call sites are + deleted with it, so the three commands share one answer by construction rather + than by three people remembering. This is the drift the suite was created to end + (#3583 §5 D5) and which its own header cited this rule as the standing proof of. + + Two things made the wiring indefensible rather than merely untidy: + + - `validateFlowNodeWrites` (#4369) walks the **same** `config.fields` map to ask + the other half of the question — "does this field exist?" against "is it + writable?" — and is already a suite member. One map, two checks, two different + command sets. + - The two hand-wired sites did not even agree with each other on their input: + `validate` passed the PRE-parse `normalized` stack, `compile` the POST-parse + `result.data`. Verified equivalent for this rule before collapsing them onto + the suite's post-parse input, so no finding is lost. + + No rule behaviour changes: same ids, same severities, same messages. + +- 31e0be9: Flow metadata is canonicalized inside structured regions, not just at the top level (#4347). + + `registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion + table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them + walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in + its open `config` (`loop.config.body`, `parallel.config.branches[]`, + `try_catch.config.try`/`.catch`), so all three stopped at the container and metadata came + out **position-dependent**: the same node converted at the top level and did not one level + in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a + top-level edge and left a bare string on a loop-body edge. + + The reporting app shipped three sweeps whose gates never opened. Each run reported + `success: true`, queried correctly, selected exactly the right records, and then did + nothing — which is indistinguishable from "this sweep had no work to do" unless you assert + on records written. + + - **`mapFlowNodes` recurses into regions**, to any depth. Every conversion in the table now + reaches a nested node, which matters most for the two that change behaviour rather than + spelling: a `webhook` / `http_request` callout inside a loop body kept a type no executor + owns (the run failed), and a `delete_record` kept `config.filters`, leaving the canonical + `filter` the executor reads absent — the erased-condition hazard + `flow-node-crud-filter-alias` exists to prevent. Notice paths carry the region + (`flows[0].nodes[3].config.body.nodes[1].config.filter`), so the warning points at the + node to edit. + - **New `normalizeControlFlowRegions`**, called at the load seam after + `validateControlFlow`: each region is parsed through its own schema (recursively — regions + nest), so nested edges and nodes carry the same canonical shapes as top-level ones. A + region that does not parse is left untouched; rejecting one stays `validateControlFlow`'s + job, so which flows register is unchanged. + - **New `collectFlowGraphs`** yields a flow's own graph plus every nested region, each with + a scope label. Both predicate validators iterate it instead of `flow.nodes` — the engine's + `validateFlowExpressions` and `@objectstack/lint`'s author-time + `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is now + caught inside a loop body too, naming the region (`loop 'sweep' body · edge 'b1' …`). It + used to pass `objectstack validate`, pass registration, and fail at run time with the + diagnostic suppressed. + + The container executors already parse their own config at run time (`parseNodeConfig`, + #4277), so a nested predicate did evaluate correctly on current `main` — what was still + wrong is everything that reads a region _without_ re-parsing it (the Studio designer, + `getFlow`, the version history), and every conversion, none of which the executors replay. + + Also hardened, per the issue's secondary finding: `evaluateCondition`'s legacy `{var}` + template path **refuses an unresolved dotted reference** instead of comparing it as a + string. `'oppRecord.amount > 500000'` was compared `'oppRecord.amount' > '500000'` — `'o'` + against `'5'` — so it was constantly true regardless of the amount: silently wrong in the + _true_ direction, a gate that reports success while never gating. It now throws with the + source and the fix (a CEL envelope, or brace the reference if the `{var}` dialect was + meant), the same "never swallow a broken predicate" rule ADR-0032 §1c set for the CEL path. + The `try { … } catch { return false }` around that block went with it: nothing in it throws, + so it guarded nothing and would have swallowed the new refusal straight back into the silent + wrong answer. Bare-word comparisons (`'{status} == active'`) and `{var}` templates are + unchanged — only dotted references, which substitution can never leave behind, are refused. + +- 4bfd455: One declaration of where ADR-0031 regions live (#4401). + + A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the + type system says which key on which node type holds one, so every pass that needs to reach + a region node has to be told — and within one week three of them were told separately, by + two changes that were each correct on their own: + + | pass | package | table it carried | + | --------------------------------------------------------------------------- | ------- | ------------------- | + | `mapFlowNodes` (ADR-0087 conversions) | `spec` | `FLOW_REGION_SLOTS` | + | `validateControlFlow` / `normalizeControlFlowRegions` / `collectFlowGraphs` | `spec` | `regionSlotsOf` | + | `walkFlowNodes` (lint flow rules) | `lint` | `REGION_SLOTS` | + + Each pinned its own copy with its own reconciliation test. So every copy was protected from + drifting away from the schemas, and **nothing would have failed if the copies drifted from + each other** — while adding a fourth construct meant editing three places, and missing one + reproduces exactly the silent blind spot #4347 and #4380 were both filed about. + + - New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (plus the + `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is now the only statement + of the fact. It lives in an **import-free** module so `spec/conversions/walk.ts` can read + it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema + its value parses as stays in `control-flow.zod.ts`, which is schema business. + - The three reconciliation tests collapse into one, `region-slots.test.ts`, keeping the + strongest of them: it derives each construct's region keys **behaviourally**, by asking + the config schema what it actually accepts in a region shape, rather than reading names + off `.shape`. It also probes every other exported `*ConfigSchema`, so a new + region-bearing construct cannot be added without either declaring its slots or failing + here. + + The three **walks** are deliberately left separate. They take different inputs (parsed + `FlowNodeParsed` vs raw authored records), yield different units (a graph, a node, a + copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node + labels — consumer logic, not protocol (Prime Directive #2). Merging them would trade a + duplicated four-line table for a walker that serves nobody well. Only the fact they all + need is shared. + + No behaviour change: every existing test passes unchanged, which is the point of the + exercise. + +- 1bd2795: feat(spec,lint): the `ui` vocabularies admit what the renderers implement, and derive instead of restating (objectui#2945) + + Additions-only follow-up to the vocabulary audit + (objectstack-ai/objectui#2901, #2945). Nothing here narrows a vocabulary, so no + already-stored metadata changes meaning — three of the four `ui/` enums that had + drifted from what is actually implemented, plus the fork that drift had made + invisible. + + **`ChartTypeSchema` admits `combo`.** The taxonomy could not name the one chart + family the rest of `chart.zod.ts` is written for: `ChartSeriesSchema.type` + exists to override a series' type — its doc comment literally says _"combo + charts"_ — and `ChartSeriesSchema.yAxis` binds a series to the left or right + axis, which is only meaningful for mixed marks. objectui's renderer draws it + distinctly (mixed bar/line/area on dual axes, per-series type) and had to carry + `combo` in a local fork of this list, whose own comment claimed to mirror it. + + **`WidgetActionTypeSchema` is `ActionType`.** The two disagreed by one member, + `form`, and the disagreement was backwards: a dashboard header or widget action + button dispatches through the same `ActionRunner` that implements `form` — + objectui's `DashboardRenderer` deliberately routes everything except a raw `url` + into it, so a `flow` header action works (#3528). The narrower enum therefore + rejected at validation exactly what the shared dispatcher then executes. + Derived, so the next type the runner implements needs one edit, not two. + + **`ListChartConfigSchema.chartType` is `ChartTypeSchema.extract([...])`.** Same + five members as before — a de-duplication, not a widening. A member renamed in + the taxonomy now fails at build time instead of leaving a second list quietly + disagreeing. + + **`@objectstack/lint`'s chart-family set is derived from the taxonomy.** + `validate-widget-bindings` decides which widgets need a `chartConfig` measure + mapping from a hand-written list of families, and its omissions fail in the + worst direction: an unlisted family reads as _"not a chart"_, so a widget + missing its mapping **passes** validation. `combo` was exactly that case — + verified by pinning the old list back, where a `combo` widget with no + `chartConfig` produced zero findings. The set is now the taxonomy minus an + explicit `MEASURE_EXEMPT_CHART_TYPES` (single-value and tabular families), so a + family added to the spec is covered without editing the rule. + + Guards: `packages/spec/src/ui/vocabulary-derivation.test.ts` asserts both + derivations still hold (a restated list fails silently — it keeps validating, + just not what the other list says), and the lint suite now walks every + multi-series family in the taxonomy rather than a list of its own. + + A third ratchet already existed and did its job: `app-showcase`'s coverage test + requires a gallery widget for every distinctly-renderable `ChartType`, and it + failed the moment `combo` was admitted. The Chart Gallery dashboard now + demonstrates it — a task count as bars on the left axis, an average as a line on + the right, which is the configuration `series[].type` / `series[].yAxis` exist + for. + + `ActionType` deliberately does **not** gain `navigation`, which the audit + suggested. `ActionRunner.executeNavigation` is a strictly weaker + `executeUrl` — no `${param.X}` interpolation, no `apiBase` promotion, no + `openIn` — differing only by a `replace` option, and its one live producer is + the SDUI `element:button` `action` prop, which `ElementButtonPropsSchema` does + not model at all. Promoting the name would add a second spelling of _navigate_ + to a closed authorable vocabulary (members cannot be removed later) without + closing the gap that actually exists. Tracked separately. + + Verified: `@objectstack/spec` **6944 tests / 267 files**, `@objectstack/lint` + **544 tests / 37 files**, both green; `tsc --noEmit` clean on both. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/sdui-parser@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index a6dcd2d3ae..f7c75a7e42 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 150e477d15..005c556dff 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,218 @@ # @objectstack/plugin-mcp-server +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b1fb5b8d40..b605f19c3d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 8cca3e4037..48332af280 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,226 @@ # @objectstack/metadata-core +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 033afee556..7a7c3eba87 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 d872cd2203..9ba6f066f0 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/metadata-fs +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [f5a4ef0] +- Updated dependencies [2e836de] + - @objectstack/metadata-core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index c72fb33fb2..033752ac37 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 fd650649b4..630dae5b5f 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,1385 @@ # @objectstack/metadata-protocol +## 17.0.0-rc.1 + +### Major Changes + +- 77fadbf: fix(metadata-protocol,objectql)!: retire the degraded analytics shim — the `analytics` slot stays empty without service-analytics (#3891, #3878) + + The protocol assembly (`assembleMetadataProtocol`, used by both + `MetadataProtocolPlugin` and `ObjectQLPlugin`'s built-in mode) used to register + a lightweight `analytics` fallback so `POST /api/v1/analytics/query` kept + answering on installs without `@objectstack/service-analytics`. That fallback + is **removed**, and with it the facade methods that existed only to serve it: + `ObjectStackProtocolImplementation.analyticsQuery` / `getAnalyticsMeta` (the + class no longer implements `AnalyticsProtocol`). + + Why removal instead of repair (#3891): + + - **It dropped the caller's ExecutionContext at the door.** The dispatcher + passes `context.executionContext` (#2852), but the shim's `query` was + unary — aggregation reached `engine.aggregate` with no context, the security + middleware's empty-principal branch waved it through, and **no RLS or tenant + predicate was injected**. An authenticated caller got a 200 with rows RLS + would hide. + - **It ignored the contract filter.** `AnalyticsQuery`'s canonical filter field + is `where`; the shim read only a non-contract `filters` key, so a + spec-conformant filtered request silently returned a full-table aggregate. + - **Every security gate had to be built twice** (#3770 on the shim vs + #3867/#3875 on the real engine) — the "duplicates logic only, harmless" + assessment in ADR-0076 D10 did not survive contact with reality. + + `getDiscovery()` stops hardcoding analytics as an always-on kernel service — + the entry is now computed from the service registry like every other optional + service (`enabled: false, status: 'unavailable'` and **no advertised route** + when absent), which also removes the pre-#2462 discovery lie the shim was + originally invented to make true. + + **Migration.** Deployments that relied on the fallback (programmatic + `createStandaloneStack()` / `createObjectQLKernel()` embeds, hosts whose bundle + doesn't require `analytics`): install `@objectstack/service-analytics` and + mount `AnalyticsServicePlugin` — the real, context-aware engine. Without it, + `/api/v1/analytics/*` now answers **404 ROUTE_NOT_FOUND** (previously: 200 with + unscoped, unfiltered aggregates) and discovery reports + `analytics: { enabled: false, status: 'unavailable' }`. Callers of + `protocol.analyticsQuery(...)` / `protocol.getAnalyticsMeta(...)` must use the + `analytics` service (`kernel.getService('analytics')`) instead. `os serve` + default/full presets and managed environments already force the real engine and + are unaffected. + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- f4d7f1d: fix(metadata-protocol,rest): the id list is the only thing deleteMany can select on (#3897) + + `deleteManyData` built the predicate its endpoint is named after and then spread + the caller's `options` **over** it: + + ```js + return this.engine.delete(request.object, { + where: { id: { $in: request.ids } }, + ...request.options, // ← lands after `where`, so it can replace it + }); + ``` + + `request.options` is caller-supplied — `POST /data/:object/deleteMany` splatted + the whole request body into the protocol request (`{ object, ...req.body }`) — + so one body key rewrote the operation: + + ```json + { "ids": ["a"], "options": { "multi": true, "where": {} } } + ``` + + reached `engine.delete` as an unscoped bulk delete. The engine's write + middleware still composes RLS/sharing predicates onto the AST, so the blast + radius is not automatically the whole table: it is **everything the caller is + allowed to delete**. For an ordinary user with delete permission that is the + difference between the 3 records they asked for and every record they can see; + measured on a stock CRM dev deployment, that payload against one id removed all + 8 rows in the object and returned the raw driver count (`8`). The same spread + also accepted `context`, i.e. a forged principal wherever the route is reachable + without auth. + + **The id set is now authoritative, structurally.** The engine options are built + from the validated id list and nothing else — caller `options` is a + `BatchOptions` bag (`atomic` / `returnRecords` / `continueOnError` / + `validateOnly`) that carries nothing `engine.delete` consumes, so merging it + could only ever smuggle in engine keys. Ids must be scalars, so an operator + object (`{"ids":[{"$ne":null}]}`) cannot reach `where.id` either; a malformed + list is a `400 VALIDATION_FAILED` instead of a wider delete. The REST route + parses the body against `DeleteManyDataRequestSchema` first, one hop earlier — + Zod object schemas strip unknown keys, so `options.where`, top-level `where` and + a body `context` no longer survive the ingress at all. + + **The endpoint also works now.** `deleteManyData` never set `multi`, so a + correctly-formed `{"ids":[…]}` hit the engine's + `'Delete requires an ID or options.multi=true'` throw — only the requests that + triggered the override above ever completed. Deletes now go one id at a time by + primary key, the same shape `batchData`'s `delete` case uses, which closes two + gaps behind that: the bulk branch skips `cascadeDeleteRelations`, so + `deleteBehavior` (`cascade` / `set_null` / `restrict`) was not honoured for the + rows it removed; and the declared `BatchUpdateResponse` contract (per-record + `results`, `atomic`, `continueOnError`) was unimplementable from a bulk row + count. Both are delivered rather than declared. + + **Behaviour change.** The endpoint returns a `BatchUpdateResponse` + (`{ success, operation, total, succeeded, failed, results }`) where it + previously returned the driver's raw delete count — on the paths where it + returned anything at all. The caller's execution context is threaded to every + delete, so RLS/FLS now run under the caller here as they do on the single-record + route. + +- b09d8d9: refactor(data)!: `query.distinct` is removed, and with it the mis-wired REST count suppression (#4286 step 4) + + `distinct` promised `SELECT DISTINCT` and no driver ever rendered it — but it + was **mis-wired rather than merely dead** (#4286 finding 2, the harsher + ADR-0078 class): its only observable effect platform-wide was that the REST + list path treated a distinct query as _not countable_, silently degrading + `total`/`hasMore` to a page-local estimate while still returning duplicate + rows. A caller — or a self-verifying agent — saw the response change and + concluded the flag worked. It had a shipped public producer + (`QueryBuilder.distinct()`). + + **FROM → TO** + + | Was | Now | + | :--------------------------------------- | :-------------------------------------------------------------------------------- | + | `distinct: true` for unique combinations | `groupBy: ['category']` | + | `distinct: true` + count | `aggregations: [{ function: 'count_distinct', field: 'category', alias: '...' }]` | + | one column's distinct values | the SQL/memory drivers' `distinct(object, field)` door (driver-level) | + + The one-line fix: **delete the key**; deduplicate with `groupBy` / + `count_distinct`. + + Mechanics: `retiredKey()` tombstones on both declaration sites + (`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared + prescription); `QueryBuilder.distinct()` is deleted; registered as the + protocol-17 semantic migration `query-distinct-retired`. **Observable REST + change (`@objectstack/metadata-protocol`):** the count-suppression branch is + deleted — a list request that used to carry `distinct` now gets a real + `total`/`hasMore` again (that restoration is the point, not a side effect). + The per-aggregation `distinct` flag (`AggregationNode.distinct`) is a + different, live member and is untouched. + +- b09d8d9: feat(objectql)!: `query.having` is enforced — the engine applies it after aggregation (#4286 step 3, ADR-0049 resolved to enforce) + + `having` had been declared on the request surface since AST v2 and executed by + nothing. #4286 finding 1 showed the gap was structural: `engine.aggregate()` + rebuilt the driver AST with exactly `object`/`where`/`groupBy`/`aggregations`, + so even a driver that _did_ implement HAVING could never have received it, and + the one wire path (`findData`'s aggregate branch) dropped the clause too. It + was the strongest enforce candidate of the #4286 set — the clause every + SQL-literate author (human or model) expects to work next to + `groupBy`/`aggregations` — and it is now live end to end: + + - **Engine-owned, both paths.** `applyHaving()` + (`packages/objectql/src/having-filter.ts`) runs AFTER aggregation on the + native-driver path and the in-memory fallback alike — the same + correct-first / optimize-later two-tier shape date bucketing uses. Native + SQL `HAVING` pushdown can come later behind a driver capability flag without + changing semantics. + - **Namespace: the aggregated row's own columns** — aggregation aliases + (`order_count`, `total`) and groupBy projections — with the ordinary + FilterCondition operators plus `$and`/`$or`/`$not`. + - **An unknown operator rejects loudly.** Ignoring one (as tolerant matchers + do) would silently return unfiltered aggregates — the exact ADR-0078 + silently-inert failure enforcement exists to end. + - **The wire path forwards it.** `findData`'s aggregate branch passes + `having` through, and `EngineAggregateOptionsSchema` now declares it. + - The FLS predicate guard already walked `having` references + (`predicate-guard.ts`), which is what made enforcement safe to turn on. + + No migration needed: queries that carried `having` before were silently + returning every group; they now filter as written. A caller who depended on + the clause being _ignored_ (sending `having` and expecting unfiltered + results) sees the corrected behavior — that is the enforcement, not a + regression. + +- 4475c59: fix(metadata)!: a `$filter` array that is not a filter AST is rejected, not passed through (#4121) + + `isFilterAST` was being read as a _conversion_ gate: an array it refused was + assigned to `options.where` unconverted, leaving each backend to make sense of a + value the protocol had already decided it could not parse. + + Item 2 of #3948, filed as error-locality work. The investigation found it is + more than that. + + **It closes the last silently-unfiltered shape.** #3948 made the drivers throw + on a bare triple with an unknown operator and on any element that is neither a + join keyword nor a condition array. What it could not reach is a lone `['and']` + or `['or']`: the driver sets its join mode, matches no element, emits **no + predicate**, and returns every row. `isFilterAST` refuses it (a logical node + needs `length >= 2`), so it arrived as an opaque `where` and no driver-side + check applied. That is now a 400. + + **For every other shape this is not a narrowing.** driver-sql throws on all of + them, driver-memory throws, driver-mongodb reaches its own parser and fails at + the server. Rejecting at the protocol changes _which_ error the caller sees, not + _whether_ there is one — and the message is in the request's own vocabulary + (`unrecognised operator "not in"`, `element 1 is number`, plus the recognised + operator list) rather than a driver's internal builder state. + + Scoped narrowly, because the regression to fear is rejecting something valid: + + - only `Array.isArray(filter)` values are in scope — a `where` **object** is + untouched, including `$and`/`$or`/`$gte` shapes; + - an empty `[]` is left alone: it means "no filter", and every path already + treats it that way; + - `isFilterAST` accepts nested arrays, so `[[a,'=',1],[b,'=',2]]` and + `['and', […], ['or', …]]` keep converting. A naive "arrays are suspect" rule + would have broken exactly those, which is why the accepted shapes are pinned + by more tests than the rejected ones. + + Errors carry `status: 400` and `code: 'INVALID_FILTER'`, matching the + `UNSUPPORTED_QUERY_PARAM` convention alongside. + + Verified: 12 new tests driving the real `findData` normalisation, not a + re-implementation of its rule — six for shapes that must keep converting, six + for shapes that must be rejected, including the exact message text. Reverting the + change fails six of them. Full `@objectstack/metadata-protocol` suite: **122 + tests across 19 files**, green. + +- 8d5bb5a: feat(metadata): `saveMeta` persists the operator spellings the spec normalized (objectui#2945) + + `ViewFilterRuleSchema.operator` is `z.preprocess(normalizeFilterOperator, …)`, so + a stored `notEquals` / `gt` / `isNull` is folded to its canonical form during + save-time validation — and then the result was thrown away. `saveMetaItem` + persists the authored body verbatim, deliberately: `parsed.data` strips the + Studio-only auxiliary fields (`isPinned`, `isDefault`, `sortOrder`) that ride + along with an overlay document (ADR-0005 §Validation). + + The consequence is that **every save mints new legacy-alias rows.** The ~30 + entries in `VIEW_FILTER_OPERATOR_ALIASES` are documented as _"a migration bridge + [that] may be dropped in a future major"_, but there is no point at which the + last alias row is behind you, so the bridge can never be dismantled — a + migration that rewrote every existing row would be obsolete the moment the next + console personalization PUT landed. That is prerequisite 2 of the vocabulary + consolidation blocked in objectstack-ai/objectui#2945. + + `graftNormalizedOperators` grafts the normalization back on without giving up the + verbatim body. It walks the authored value and `parsed.data` in lockstep **by + structure** and copies across exactly one thing: an `operator` whose parsed value + differs from the authored one. + + - **No key list to maintain.** `ViewFilterRule[]` appears at five declared sites + today (view `filter`, `ViewTab.filter`, page `filterBy`, and two + `component.zod.ts` block props) and the structural walk covers all of them, + plus any added later. Enumerating paths would have reproduced in this file the + exact duplication #2945 exists to remove. + - **Nothing else moves.** Only an `operator` string is rewritten, and only where + both sides are strings — so a `$`-token `FilterCondition`, a different operator + vocabulary entirely, cannot be reshaped by accident. No key is added, removed, + reordered or defaulted; the unary `{field, operator}` form does not acquire a + `value` even though the schema's own output would give it one. + - **Nothing is allocated when nothing changed**, so a body already written in + canonical form is returned by identity. + + Behaviour change worth stating plainly: a `GET` after a `PUT` now returns the + canonical spelling rather than the one the author sent. That is the spelling the + spec defines, every renderer accepts it (objectstack-ai/objectui#2974, + objectstack-ai/objectui#2989 pinned all three of objectui's translation tables to + the full vocabulary), and it is the point of the change. Existing rows are not + touched — this stops the bleeding, it is not the migration. + + Verified: 11 new tests, including one that drives **every** alias the spec still + folds through the real `ViewMetadataSchema` and asserts the persisted body comes + out canonical; full `@objectstack/metadata-protocol` suite 110 tests / 18 files + green. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 0373d52: Both discovery builders now derive the `data` service entry from the implementation in the slot, closing the hardcoded "kernel-provided" block (#4130). + + #4089 computed `metadata`; `data` was the last entry that judged itself, reporting `status: 'available'` and `handlerReady: true` unconditionally. That was true — but by a convention in a different package, not by anything either builder checked: ObjectQL is the slot's only producer, and plugin-dev always loads `ObjectQLPlugin` as a child, so plugin-dev's `data` stub (`find()` returns `[]`, `insert()` mints an id and stores nothing) never reaches the slot. A second producer, or a trimmed dev config, and the hardcode starts lying about the platform's most load-bearing capability. + + Both builders now read the registered service's `__serviceInfo`: + + - a real engine carries no marker ⇒ `available` + `handlerReady: true`, byte-identical to the hardcode it replaces (verified on a real kernel boot); + - a self-declared stub ⇒ its own `status` and `message`, with `handlerReady: false` (the default for `stub`), so a consumer that gates on `handlerReady` stops treating an empty query engine as a real one. + + `handlerReady` is derived here rather than pinned `true` as it is for `metadata`, because the two routes differ: `/meta` answers from the protocol whatever fills the metadata slot, while `/data` needs the `protocol` or an objectql-shaped service and 503s without them — and the only stack where a stub occupies the `data` slot is one where ObjectQL never registered. No routing, gating or dispatch behavior changes: the `data` domain resolves its engine directly and never consulted this slot. + +- 4f30943: Both discovery builders now compute the `metadata` service entry from the implementation that fills the slot, instead of hardcoding opposite verdicts for it (#4089). + + `metadata` sat in a "kernel-provided (always available)" block above the loop that reads `__serviceInfo`, hardcoded separately in each builder — and the two disagreed about the same slot: + + - `@objectstack/runtime`'s dispatcher declared it permanently `status: 'degraded'` with `message: 'In-memory registry; DB persistence pending'`, so a stack with `MetadataPlugin` and a real `sys_metadata` table was still reported as having no persistence. + - `@objectstack/metadata-protocol` declared the same slot permanently `status: 'available'`, so the kernel's in-memory fallback (`createMemoryMetadata`, auto-registered when no metadata plugin is present) read exactly like a persisted registry — the `__serviceInfo` marker #4058 gave it went unread here. + + Both now read the registered service's `__serviceInfo` (via `readServiceSelfInfo`) and report what it declares: + + - kernel in-memory fallback, or plugin-dev's dev registry → `status: 'degraded'` plus that implementation's own `message`, which names what is missing and what to install. + - `MetadataPlugin` (or any implementation carrying no marker) → `status: 'available'` with no message. + + `handlerReady: true` is now stated unconditionally on both sides: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol whichever implementation occupies the slot — a degraded service in it does not unmount the route. Nothing about routing, gating, or dispatch changes; consumers that treat `status` as a capability claim (AI agents, the console) simply stop being told two different things by two hosts. + +- 86a71d1: Discovery's "install this to enable" now names a package that exists (#4093 follow-up). + + Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name. + + The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist** — `plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`. + + A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". + + `CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name. + + Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one. + + `scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind. + + FROM → TO: `services..message` and `services..provider` change text for most unavailable slots. Anything matching on the old `Install a plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents. + +- bb192c4: Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2). + + #4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction. + + - The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree. + - `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected. + - `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would. + - `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored. + + No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself. + +- ed77493: fix(objectql,spec): `filter` folds to `where` on EVERY engine method, and `top`/`limit` joins the #3795 slot table (#4346) + + The `filter` → `where` fold that #3795 settled at the protocol layer existed + at the **engine** layer in exactly one of six methods. `ObjectQL.find()` + folded it; `findOne`/`count`/`update`/`delete`/`aggregate` passed the option + bag through with `ast.where === undefined`, which every driver reads as "no + predicate" — so a caller filtering with `{ filter }` silently matched EVERY + row: + + | call | before | after | + | ------------------------------------ | ----------------------- | -------------- | + | `findOne({filter: {status:'done'}})` | first row of the table | a matching row | + | `count({filter})` | whole-table count | matching count | + | `update(data, {filter, multi:true})` | **every row rewritten** | matching rows | + | `delete({filter, multi:true})` | **table emptied** | matching rows | + | `aggregate({filter, …})` | aggregated all rows | matching rows | + + This was reachable, not theoretical: the deprecated + `DataEngine{Query,Update,Delete,Count,Aggregate}OptionsSchema` contracts all + declare `filter`, `ScopedContext`/`ObjectRepository` (the cross-object API + handed to L2 hook bodies) forwards its argument verbatim, and the spec's own + hook documentation taught the broken call + (`users.findOne({ filter: { role: 'admin' } })` — now corrected to `where`). + + Every engine entry point now folds through the spec's own #3795 machinery + (`RPC_QUERY_ALIAS_SLOTS` + `foldQueryAliasSlots`) instead of `find`'s + hand-rolled copy, under the #4181 rule: an alias alone folds, redundant + identical spellings collapse, DIFFERENT values for one slot throw + ("Send exactly one") instead of silently picking a winner, and an explicit + `null` alias is a withdrawal. + + **The sixth pair.** `top` → `limit` — the pair the #3795 scope note excluded + as "the OData layer" — joins `RPC_QUERY_ALIAS_SLOTS`. The protocol normalizer + folded it BACKWARDS (`options.limit = Number(options.top)` — the alias + overwrote the canonical key) while `engine.find` folded it canonical-wins, so + `{top: 1, limit: 3}` answered 1 over HTTP and 3 through a direct engine call. + All three readers (wire normalizer, RPC schema parse, engine) now resolve the + pair identically: `top` alone still limits, a conflicting `{top, limit}` is + refused. + + Behavior change to note: option bags that previously smuggled conflicting + spellings (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are now refused + loudly on every path instead of silently resolving differently per layer. + Pinned per method, write paths included — a regression here is silent and + destructive, and the class went unnoticed precisely because `find` was the + only method anyone thought to check. + +- 58a03d2: fix(objectql,spec,metadata-protocol,service-queue): engine option bags are now a closed contract — unknown keys throw instead of silently doing nothing (#4371 option 2) + + The engine declares `Engine*OptionsSchema` but never parses it at runtime, so + any option key outside the contract — a typo (`orderby`), a retired key + (`cursor`), a wire-protocol leftover (`object`, `count`), a key that only + works on other methods (`tenantId` on `count`) — rode along and was silently + ignored. All six methods now reject non-null unknown keys, naming the legal + set; retired keys (`cursor`/`distinct`) quote their #4286 tombstone; `null` + stays a withdrawal. + + Per-method legal keys = the method's schema keys plus the documented extras: + `searchFields` (now declared on `EngineQueryOptionsSchema` — it was read by + the engine's `$search` expansion and sent by the protocol layer all along), + `onFieldsDropped` on `update` (contract-declared write observability), and + the driver pass-through keys (`transaction`, `tenantId`, `tenantIds`, + `timezone`, `bypassTenantAudit`, `preserveAudit`) on `find`/`findOne`/ + `update`/`delete` — the methods whose bag actually reaches driver options. + `count`/`aggregate` never forward their bag, so pass-through keys there are + rejected rather than accepted-and-ignored. A drift pin holds the sets equal + to the schemas. + + Also closed in the same sweep: + + - A bag-level `object` key used to OVERRIDE the resolved object on the query + AST (`{ object, ...query }` spread order), splitting `ast.object` from the + table actually queried. The AST now keeps the resolved name; a direct call + passing `object` is rejected, and the protocol layer refuses a POST-body + `object` that contradicts the route (400 `QUERY_OBJECT_MISMATCH`) instead + of picking a winner. + - `findData` no longer leaks protocol-layer vocabulary (`object`, `count`, + `joins`, `windowFunctions`, `cursor`, `distinct`, non-aggregate `having`) + onto the engine bag. + - Nested expand ASTs (`expand: { rel: { sort } }`) reject the four wire-only + spellings exactly like the top-level bag (#4371 option 1 did the top level). + - The engine's OData-spelling reads (`$search`/`$searchFields`) are gone — + the protocol normalizes to the bare keys; a direct call passing them now + throws instead of half-working on one method. + - `DbQueueAdapter.purge`/`purgeFailed` passed `{ id }` — a key the engine + never read, so purge deleted NOTHING (each delete threw into a warn-level + catch) and purgeFailed always threw. Both now pass `{ where: { id } }`; + the test fake's `delete` no longer accepts the signature the real engine + rejects. + + Migration for direct engine callers (wire/HTTP callers are unaffected): pass + only the keys your method's `Engine*OptionsSchema` declares (plus the extras + above). Anything else previously did nothing — delete it, or move it to the + layer that owns it. + +- e59786e: fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) + + A recursive Zod schema needs an explicit annotation to break its circular + inference, and five of them took the cheapest one available: + + ```ts + export const NavigationItemSchema: z.ZodType = z.lazy(() => …); + export type NavigationItem = z.infer; // → any + ``` + + It compiles, it validates correctly at runtime, and it silently throws the type + away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all + `any` on the published surface, plus `FieldNodeSchema` — which had no exported + type alias yet, so `z.infer` was `any` and + `QueryAST['fields']` with it. + + That is worse than a missing export. #4115 tells every consumer that a local + declaration under a spec export's name must be replaced by a binding to the + spec — and for these, obeying it **replaced a precise type with `any`**. + objectui's `NavigationItem` is a 118-line documented interface (`recordId` + template variables, `requiresObject` / `requiresService` capability gates, + `filters` precedence); every key of it exists in the spec's version, so by every + available signal it read as a redundant fork safe to delete. Deleting it swapped + a fully-typed interface for `any`, with no compile error anywhere to say so. + + It is hard to catch by inspection because `any` is mutually assignable with + everything, so the natural "are these the same type?" check answers _yes_ in both + directions and recommends precisely the wrong action. Same failure family as + #4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything + reads as agreement. + + **Now annotated with the real type**, using the pattern `QueryAST` already + follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive + knot in the type, so the keys stay derived from the schema instead of being + hand-maintained beside it: + + ```ts + const BaseXSchema = z.object({ …every non-recursive key }); + export type X = z.infer & { children?: X[] }; + export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + children: z.array(XSchema).optional(), + })); + ``` + + `z.infer` now resolves to the type it should always have been: `NavigationItem` + is the nine-branch discriminated union, `FormField` the 30-key form-field + contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into + `visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` + the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime + validation is unchanged: every schema parses exactly what it parsed before. + + **What the types immediately caught**, none of it visible while they were `any`: + + - `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has + never declared. It worked only because objectui's `NavigationRenderer` still + falls back to that legacy alias. Fixed at the producer per Prime Directive + #12: the canonical key is `expanded`. + - The MongoDB driver built its projection with `projection[field] = 1` over + `query.fields`, so a relationship `FieldNode` would have keyed the projection + on `"[object Object]"`. It now reads the node's field name. + - `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated + with the PARSED `App` / `NavigationContribution` types but omitted + `.default()`ed keys (`expanded`, `target`), as did the form fields + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the + default it was relying on, matching what the surrounding literals already do + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. + + **Gated, not just fixed** (`check:exported-any`, wired into the required + `TypeScript Type Check` job). `api-surface.json` records that an export _exists_ + and never what it _resolves to_, which is how these survived a whole major with + every gate green. The new scan reads the built `.d.ts` a consumer's import + actually resolves to and fails on any exported type that resolves to `any` — or + any exported schema whose output is `any`, the root cause, and the only reason + `FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and + currently empty. It self-tests against the real zod first, so if the internals it + reads are ever renamed the gate fails loudly instead of quietly passing + everything forever. + +- a4a9944: fix(metadata-protocol): findData must not take its execution context from the request (#3960) + + Came out of the #3946 sweep's leftover question — whether `expand`'s "advanced + usage" (a caller-supplied `Record` whose sub-ASTs each carry an + `object`) is a cross-object read channel. **It is not**, and that needs saying + because the answer is load-bearing: `expandRelatedRecords` takes its target from + the parent schema (the expand KEY must be a real `reference` field; the sub-AST's + `object` is never read), re-enters `engine.find` so the referenced object's RLS + + FLS both run, `$and`-merges a nested `where` instead of spreading it over the id + filter, and caps depth. No change needed there. + + What the investigation did turn up is one layer down. `findData` built its engine + options as `{ ...request.query }` and then assigned `context` from + `request.context` **conditionally**: + + - `request.query` is the caller's raw bag on every ingress — the REST + `POST /data/:object/query` route passes `req.body` straight in as `query`; + - `context` sits in the known-params set, so it was not swept into the + implicit-filter bucket either — it survived the spread untouched; + - so when no server context resolved, the caller's `context` _became_ the + operation's execution context. + + Everything hangs off that value. plugin-security's middleware opens with + `if (opCtx.context?.isSystem) return next()` — the entire RLS / FLS / CRUD chain + skipped — and `__expandRead: true` collects the #2850 waiver on the object-level + CRUD gate. Neither is ever schema-stripped on the read path: + `ExecutionContextSchema.parse` runs only in `engine.createContext`, which reads + do not use. + + Route-level `enforceAuth` is what kept this unreachable: anonymous data requests + are refused unless a deployment sets `requireAuth: false`. That makes it a + fail-OPEN default rather than a live exploit — and not something the protocol + should delegate upward. `findData` now drops any inbound `context` + unconditionally before the assignment, so the execution context can only come + from `request.context`. + + Verified end-to-end at the protocol layer (a forged + `{ isSystem, userId, __expandRead }` reached `engine.find` verbatim before, is + dropped after). The anonymous HTTP reachability half is NOT verified — see #3960 + for exactly what was and was not reproduced. No caller regresses: the only + in-repo builder of these args (`rest/src/import-runner.ts` `findArgsBase`) passes + `context` at the top level, never inside `query`. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- f5fe061: fix(data): implicit field filters compose with an explicit `filter` by AND instead of being silently dropped (#4164) + + `GET /api/v1/data/:object?filter={"status":"open"}&owner_id=usr_1` used to + apply only the explicit filter: the bare `owner_id` predicate was neither + merged nor reported — it rode to the engine as a stray AST key no driver + reads, and the response over-returned. The mirror of #4134's silent zero, + same disease, opposite direction. + + The two now compose the way the request reads: `{ $and: [explicit, implicit] }` + — the same combinator the engine already uses to fold the `search` predicate + into an existing `where`, and one the cross-backend filter-logic conformance + suite pins. Contradictory sides (`?filter={"status":"open"}&status=closed`) + apply both predicates and intersect to an honest empty set. Pagination totals + (`total` / `hasMore`) are computed over the merged predicate, so they cannot + disagree with `records`. + + **What changes for callers:** requests that sent both an explicit `filter` and + bare field parameters now get the narrower, as-written result set instead of + the explicit filter alone. Requests sending only one of the two mechanisms are + unaffected. Thanks to #4134 (shipped previously), every bare parameter that + reaches the merge is a verified field name, so the merge can never introduce a + zero-matching predicate. + +- 6c87cc9: fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181) + + `GET /api/v1/data/:object?filter={status:done` — one missing quote — answered + `200` with the **unfiltered** page. The JSON-parse tolerance + (`catch { /* keep as-is */ }`) left the raw string on `where`, a shape no + driver consumes, so the filter was dropped whole and the response was + byte-for-byte a successful unfiltered query. The worst failure direction in + this family: #4134 returned nothing, #4164 dropped one predicate, this + returned everything. + + The sibling `GET /data/:object/export` route had rejected the same input since + it was written — the list path was the outlier. That guard now lives in the + shared normalizer, so `GET /data/:object`, `POST /data/:object/query` and the + runtime dispatcher all give one answer: + + - Unparseable JSON → `400 INVALID_FILTER`, naming the parameter and stating the + filter was not applied. + - Parses but is not a filter (`?filter=5`, `?filter="done"`, `?filter=null`) → + same rejection; usable JSON is not a usable filter. + - Blank `?filter=` → treated as absent, as before. No error. + - `filter` / `filters` / `$filter` / `where` are four spellings of ONE slot. + Sending two with **different** values used to run one and discard the rest + silently; it is now `400 INVALID_REQUEST` (each value is a valid filter — the + _request_ is ambiguous, so it does not share the malformed-filter code). + Redundant identical spellings pass. + - `orderby` on the export route gets the same treatment — a sort that cannot be + parsed is refused rather than dropped (lower stakes than a filter: the row set + is unchanged, but a caller taking "latest N" got an arbitrary N). + + **One wire code for one condition.** #4121 landed `400 INVALID_FILTER` for + malformed filter _arrays_ on this same code path while this fix was in flight; + the non-array rejections above use that code too, so a caller asking "did my + filter run?" never has to know which branch caught it. The export route's + filter guard moves from `INVALID_REQUEST` to `INVALID_FILTER` to match — a wire + change on an existing route, and the reason it is worth making is that a client + otherwise has to handle two codes for one condition depending on which URL it + called. The route's `orderby` guard keeps `INVALID_REQUEST` (it is not a + filter). + + **What changes for callers:** requests carrying a malformed filter now fail + loudly instead of receiving every record. Every valid filter shape — JSON + string, live object, `FilterCondition` AST array, and all four alias spellings + used alone — is unaffected. + +- af2a095: fix(data): `searchFields` / `groupBy` / `aggregations` naming a field that does not exist are rejected, not silently degraded (#4254) + + #4226 closed `sort` / `select` / `expand`; with the filter axis (#4134 / #4164 / + #4181 / #4121) that made four field-naming read axes that either apply or fail. + The same machine kept leaking on the remaining three, and each failure corrupted + something the closed axes never touched: + + ``` + search=alpha&searchFields=no_such -> 200 MORE rows than the narrowing allowed + groupBy=[no_such] -> 200 [{no_such: null, n: }] N groups collapsed into 1 + sum(no_such) -> 200 0 — indistinguishable from a real zero + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `POST /data/:object/query`, the export route and the runtime dispatcher give + one answer instead of four. + + - **`searchFields` → `400 INVALID_FIELD`.** The `select` failure with the sign + flipped outward: the engine dropped unknown names and, when that emptied the + override, fell back to the FULL searchable set — so a parameter that exists + only to narrow a search widened it, and it changed which ROWS came back, not + just which columns. Its only in-framework caller is `GET /data/:object/export` + — the route whose `search` support just shipped so exports would stop + downloading "the unsearched superset … in a file that looks authoritative"; + a typo'd `searchFields` did exactly that, one parameter over. Three causes, + three messages, because the fixes differ (the split #4226 drew on expand): a + name that is no field is a request typo; a REAL field outside the searchable + set needs the object changed (its message names the declared + `searchableFields` or the auto-default's type rule, whichever applies); and + a `searchableFields` entry that names no field is a STALE DECLARATION — a + bug on the object, called out as such because clients (objectui's list + search) echo the declaration verbatim. The allowed set is resolved by the + same `@objectstack/spec/data` function the engine's search expansion + consumes (`resolveSearchFieldResolution`, moved from objectql), so the gate + cannot drift from what search actually scans. + - **`groupBy` → `400 INVALID_FIELD`.** The in-memory aggregation path projects + an unknown column as `null` for every row, so all rows landed in ONE bucket + whose count is the true row count — structurally perfect, identical to "this + column really holds a single value". A chart draws one bar; nothing says the + grouping never ran. Native SQL aggregation errors on the same input, so which + backend a deployment sits on decided the answer — the "two routes, opposite + answers" split, one axis over. + - **`aggregations` → `400 INVALID_FIELD`.** `sum()` folded a column of + `undefined` to `0` — the exact number an empty quarter produces, in reports + whose whole job is to be believed (`avg`/`min`/`max` answered `null` the same + way). `count` with no `field` (or the `'*'` sentinel) is the one legitimate + field-less form and passes. + - **Unreadable SHAPES on the aggregation axes → `400 INVALID_QUERY`** — the + standard-catalog code that had no emitter since it was written, like + `INVALID_SORT` before #4226. A string `groupBy`, an entry naming no field, a + function or `dateGranularity` outside the spec enums, a missing `alias`: each + slipped past the `Array.isArray` routing guard (rows returned UNGROUPED) or + computed a silent placeholder (`null` results, a column keyed `"undefined"`, + one bucket per raw value under an unknown granularity). + + Tiering is unchanged from #4226: registry + field map present → authoritative; + no registry / no field map / legacy array field map → the NAME gates skip (shape + gates still apply — they need no schema). The engine's own tolerance is + untouched: internal callers reaching `engine.find()` / `engine.aggregate()` + directly are unaffected. `@objectstack/rest` also stops logging + `INVALID_FILTER` / `INVALID_SORT` / `INVALID_QUERY` rejections as + "[REST] Unhandled error" — they are client mistakes the response already + explains, as `INVALID_FIELD` always was. + + Requests that name real fields are unaffected. + +- bf478e1: fix(data): `sort` / `select` / `expand` naming a field that does not exist are rejected, not silently dropped (#4226) + + The list path has four axes on which a caller names a field. `filter` was + closed over #4134 / #4164 / #4181 / #4121 — a filter the server cannot apply is + now a 400, never a 200 over the wrong rows. The other three still leaked, all + answering `200`: + + ``` + sort=no_such_field -> 200 CAEBD byte-identical to "no sort at all" + select=no_such_field -> 200 asked for one column, got all of them + expand=no_such_rel -> 200 no relation, no complaint + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `GET /data/:object/:id`, `POST /data/:object/query`, the export route and the + runtime dispatcher give one answer instead of five. + + - **`sort` → `400 INVALID_SORT`.** The row set is unchanged, so this is not + #4181's "returned everything" — it is worse in one specific way: `sort` + + `top` is how a caller asks for "the latest N", and a dropped sort makes that + an arbitrary N that nothing in the response reveals. This is the list half of + the bug #4181 fixed on the export route's `orderby`. `INVALID_SORT` had sat + in the standard catalog since it was written with no emitter. + - **`select` → `400 INVALID_FIELD`.** `engine.find()` drops unknown columns + (deliberate `SELECT *` tolerance) and then falls back to `*` when that empties + the projection, and the two compose into `?select=` asking for ONE + column and receiving EVERY column — a parameter whose purpose is to return + less, failing by returning more, against both FLS and data minimisation. The + partially-unknown case (`?select=title,no_such`) is refused on the same terms: + half a projection is not the one that was asked for, and the tolerant reading + would have to explain why `?status=` is a 400 and `?select=` is + not, on one endpoint, about one field map. + - **`expand` → `400 INVALID_FIELD`.** The lightest of the three — same rows, + same columns, the relation simply is not there — but the response cannot be + told apart from "every foreign key is null", and the client renders raw ids + where names belong. A name that is no field at all and a name that is a field + holding no reference (`?expand=title`) get different messages, since the fixes + differ. + + **Sorts that were silently never applied now are.** Two wire spellings reached + the normalizer and fell through it untouched, and every driver then declined + them (`SqlDriver` guards its ORDER BY with `Array.isArray(orderBy)`): the + client SDK's own declared `orderBy: string[]`, and the `{field: direction}` map + that `GET /data/:object/export`, `GET /data/import/jobs` and objectui's calendar + all emit. Both are now folded to `SortNode[]` — so the import-job history, which + has asked for `created_at desc` since it was written and served insertion order, + sorts. A sort shape that still cannot be read (a number, an entry naming no + field, a direction that is neither `asc` nor `desc`) is `400 INVALID_SORT` + rather than a silent no-op. + + **`$expand` of a `tree` field works.** `REFERENCE_VALUE_TYPES` lists `tree` + among the types whose value "points at another record … the related record + object in expanded form", and objectui requests it, but + `engine.expandRelatedRecords` tested membership with a hand-copied `!==` chain + that omitted it — so a hierarchy field came back as a raw parent id. The loop + now reads the shared spec set, which is also what the new expand gate validates + against, so the gate cannot admit a field the engine then skips. + + **What changes for callers:** requests naming a non-existent field in `sort`, + `select` or `expand` now fail loudly instead of receiving an unsorted, widened + or unexpanded response. Every axis naming real fields is unaffected. The + engine's own tolerance is untouched — it guards internal callers (hooks, flows, + expand sub-reads, registry-less hosts) that never pass through this ingress, + the same tiering the object-existence and unknown-field gates already use. + +- dd5daac: fix(data): reject unknown list query parameters instead of reading them as zero-matching field filters (#4134) + + `GET /api/v1/data/:object` reads any parameter it does not reserve as a + field-level equality filter — that is what makes `?status=done` shorthand for + `?filter={"status":"done"}`. When the name matched **no** field the resulting + predicate could only ever match nothing, so `?pageSize=5` on a 10-row object + returned `200` + `total: 0`: structurally valid, and indistinguishable from + "this object is empty". The write path already rejected the same unknown name + loudly (`400 INVALID_FIELD`), so one piece of knowledge — does this field + exist — was enforced on write and silently zeroed on read. + + The read path now answers the same way, in the same envelope: + + ```json + { + "error": "Unknown field 'pageSize' on object 'showcase_task'. Query parameters that are not reserved are read as field filters, so an unknown name can only match zero records. Did you mean the 'top' query parameter (OData spelling '$top')?", + "code": "INVALID_FIELD", + "field": "pageSize", + "object": "showcase_task" + } + ``` + + The rejection carries a suggestion — the canonical parameter for a known + dialect (`pageSize` / `perPage` / `page` / `sortBy` / `q` → `top` / `skip` / + `sort` / `search`), or the closest real field name when it reads like a typo — + and fires whether or not an explicit `filter` rode along, so the failure never + depends on which other parameters were sent. + + **What changes for callers:** a request sending a parameter that names no field + now gets a `400` where it used to get an empty `200`. Page size is `top` / + `$top` / `limit`; page offset is `skip` / `$skip` / `offset`. Every documented + parameter, every `$`-prefixed OData alias, and the full `QueryAST` body of + `POST /data/:object/query` are unaffected. An object with a field named after a + reserved parameter (`count`, `cursor`, `object`, `top`, `search`, …) filters it + through the explicit form: `?filter={"count":3}`. + +- 239c3a3: fix(spec)!: the #3963 / #4052 / #4158 / #4196 / #4286 retirements land in protocol **17**, not a protocol 18 that this train cannot produce (#4350) + + Ten tombstone prescriptions told authors a key "was removed in `@objectstack/spec` **18**", + and — worse — the machine agreed with them: a whole `step18` chain step and two + `toMajor: 18` conversions were wired for a major the release train does not reach. + + **17 is what ships.** `latest` is 16.1.0 and `rc` is `17.0.0-rc.0` — 17.0.0 has never been + published. `.changeset/pre.json` records `@objectstack/spec` at initialVersion 16.1.0, and + changesets computes a pre-mode bump from the last _published_ version: 16.1.0 + `major` = + **17.0.0**, released as `17.0.0-rc.N`. `PROTOCOL_VERSION` is `'17.0.0'`, and + `protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 + either. The "18" came from counting up from the in-flight `17.0.0-rc.0` instead of from + 16.1.0. + + **The prose was the smaller half.** `composeMigrationChain(from, to = PROTOCOL_MAJOR)` + filters `m <= toMajor`, so a step keyed 18 was **unreachable**: `os migrate meta --from 16` + walked steps 11–17 and silently skipped 18. The same ceiling applies to `composeSpecChanges`, + so the generated `spec-changes.json`, `docs/protocol-upgrade-guide.md` and the `spec_changes` + MCP tool — the ADR-0087 D4 primary channel — carried **none** of these seven retirements: + `query.joins`, `query.windowFunctions` and `BatchOptions.validateOnly` appeared zero times in + the committed manifest, and the upgrade guide contained no "18" at all. Authors would have hit + the tombstones with no chain hop to run and no upgrade-guide row to read. + + What changed: + + - `step18` is folded into `step17` — its rationale, both `conversionIds` + (`stack-api-require-auth-removed`, `flow-node-wait-timeout-keys-removed`) and all six + semantic migrations move across, and `MIGRATIONS_BY_MAJOR[18]` is gone. Both conversions + become `toMajor: 17` (`migrations.test.ts` requires a conversion's `toMajor` to equal its + step's major), and `CONVERSIONS_BY_MAJOR[18]` merges into `[17]`. + - All 30 hand-written "18" references become "17": the ten tombstone prescriptions + (`query.zod.ts`, `flow.zod.ts`, `rest-server.zod.ts`, `stack.zod.ts`, `protocol.ts`), the + `query.test.ts` pin regex that was holding the wrong number in place, the internal comments, + the `liveness/query.json` + `liveness/README.md` notes, and the seven unconsumed changesets. + - The seven retirements are written into the v17 release notes and upgrade checklist, where + they had no entry at all — there is no `v18.mdx` for them to have landed in. + + No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, #4196 and #4286 + stays retired, on exactly the terms those changesets describe. What changes is that the + prescription now names the version that will actually carry it, and `os migrate meta` actually + applies the two stack conversions instead of stepping over them. + +- a2266a6: fix(spec,data): the five RPC query aliases resolve by ONE fold — spec table, not per-reader prose (#3795) + + `RpcQueryOptionsSchema` accepts five legacy aliases next to their canonical + QueryAST keys and stated the precedence in prose only ("the normalizer uses + the new key"). With no fold in the schema, every reader re-implemented it — + the #3713 condition — and the two readers disagreed: + + | pair | spec prose | runtime dispatcher | metadata-protocol | + | --------------------- | ---------- | ------------------ | ----------------------------- | + | `where` > `filter` | canonical | canonical | **alias consulted first** | + | `fields` > `select` | canonical | canonical | **alias clobbered canonical** | + | `offset` > `skip` | canonical | canonical | **alias clobbered canonical** | + | `expand` > `populate` | canonical | — | **alias consulted first** | + | `orderBy` > `sort` | canonical | canonical | canonical | + + Four of five inverted in `protocol.ts`, so `?select=a&fields=b` answered + `[a]` on one path and `[b]` on the other — reachable from a plain HTTP + request. + + **The mapping now lives once, in the spec** (`RPC_QUERY_ALIAS_SLOTS` + + `foldQueryAliasSlots`, both exported), under the rule #4181 already + established for the filter pair: + + - an **alias alone** folds into its canonical key — `filter`→`where`, + `select`→`fields`, `sort`→`orderBy`, `skip`→`offset`, `populate`→`expand` — + and the alias key is **dropped from the parsed output**; + - **both spellings, same value**: redundant, tolerated, alias dropped; + - **both spellings, different values**: irreconcilable — picking a winner IS + the silent drop — so the parse fails (schema) / the request is `400 +INVALID_REQUEST` (wire), naming the spellings and the canonical key; + - an explicit **`null` spelling is a withdrawal**, never a conflict: a null + alias is dropped silently, a null canonical keeps its slot-specific answer. + + `RpcQueryOptionsSchema` and the four `filter`-mixin option schemas + (update/delete/count/aggregate requests) apply the fold as a parse transform, + so parsed output speaks canonical keys only — a TS consumer reading + `parsed.query.populate` now **fails to compile** instead of silently reading + `undefined` (the #3742 / #3764 shape, one layer down; hence the minor). The + protocol normalizer folds raw wire input by the same table (extended with the + wire-only `filters` / `$filter` / `$expand` spellings), and the runtime + dispatcher's second copy of the fold is deleted outright. + + **Authoring/callers unchanged for the supported cases**: every alias alone + keeps working on every path, and identical duplicates still pass. What + changes is mixed vocabularies with **different** values — previously answered + differently per route, now refused loudly on all of them — and a direct + `expand: [names]` array on `POST /data/:object/query`, which used to be read + by its indices ("Unknown field '0'") and now lowers to the expand record like + `populate` always did. + +- 627b188: fix(seed-loader): count reference fields dropped from rows that were still written + + The loader had two failure outcomes and only counted one. A record it cannot + write is counted in `errored`. But an unusable **reference value** (an object + where a natural key belongs, an array on a single-value field) is removed from + the record — never written as NULL, which would sever an existing link on + upsert replay — and the row is written **without it**. Nothing counted that. + + So a load that quietly severed N associations reported `totalErrored: 0`, and + every count-driven surface read clean. The CLI boot banner — the one seed signal + that survives `os dev`'s boot-quiet window and the default `warn` level — printed + `showcase 42 rows`, and the warn line said `0 dropped record(s)`: true, and + useless ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + + `SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` + now count it. It is deliberately **not** folded into `errored` — the row _was_ + written, so that would break the `inserted + updated + skipped` reconciliation + against `total`. The banner names it separately: + + ``` + ⚠ Seeds: showcase 42 ok / 3 lost links ⚠ + ``` + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected. + +- 8d4eae7: fix(seed-loader): resolve natural-key ARRAYS for multi-value lookups + + A `multiple: true` lookup / `user` field stores an array of ids, so its seed + value is an array of natural keys (`authors: ['Alice', 'Bob']`). Reference + resolution only ever accepted a single string: the array tripped the + "expected a natural-key string but got an object. Pass the target's `name` + value as a plain string" guard — impossible advice for a field that holds + several references — and was then DROPPED from the record. The row landed with + the whole association missing and only a warn in the log + ([#3911](https://github.com/objectstack-ai/objectstack/issues/3911)). + + Every element now resolves independently (in-load records first, then the + database, then pass 2), and the field lands as an array of target ids. A lone + string is accepted as one-element shorthand for the array shape the field + stores. Deferral is all-or-nothing per field — a partially-resolved array is a + corrupt association, so pass 2 re-resolves the whole authored array — and a key + that never materializes is a reported load error naming that element, not a + silent drop. + + An array passed to a genuinely **single-value** reference field is still + rejected, now with advice an author can act on: declare the field + `multiple: true`, or pass one natural key. + + `ReferenceResolution` (`@objectstack/spec/data`) gains an optional `multiple` + flag carrying the field's array-ness into resolution; it is additive and + defaulted-absent, so existing dependency graphs are unaffected. + + **Authoring types.** `defineSeed`'s per-field value type now widens a + `multiple: true` lookup to `string | string[] | null` (a lone string stays legal + — the loader accepts it as one-element shorthand). `master_detail` is inherently + single and is not widened, and an array on a single-value lookup is still a + compile error. To make that reachable, `Field.lookup` became generic over its + config (``) so `multiple: true` survives as a + literal instead of widening to `boolean`; the return type is intersected with + `FieldInput` so its optional surface is unchanged. Type-level only — the + returned object is byte-identical at runtime. + +- a62bd9e: fix(data): a dotted-path `sort` (`?sort=account.company_name`) is rejected with `400 INVALID_SORT`, not silently unapplied (#4256) + + The one sort shape #4226 deliberately left open is now closed. A dotted path + passed the sort gate on its head segment (`account` is a real field) and was + then unusable by every driver: `SqlDriver` handed it to Knex, which rendered + `"account"."company_name"` against a table that was never joined, and the + #3821 unknown-column backstop retried **without the sort**; Mongo and the + memory driver resolved the path against the row itself, where a foreign key is + a scalar id. Result: `200`, every row present, arbitrary order — and since + `sort` + `top` is how a caller asks for "the latest N", an arbitrary N with + nothing in the response to reveal it. + + The rejection distinguishes the two mistakes a dotted path can be: + + - a head that IS a relationship (`project_id.name`) — the message names the + relationship it tried to cross and prescribes the supported alternative: + denormalise the value onto the queried object (formula or rollup field) and + sort by that; + - a head that is not (`title.length`) — the message states the contract: sort + reaches only whole columns of the queried object, not values inside them. + + An unknown head (`no_such.title`) keeps the existing typo-shaped answer, and a + list carrying both mistakes reports the typo first — the same precedence the + expand gate uses. + + **What changes for callers:** requests whose sort crosses a relationship now + fail loudly instead of receiving an ordinary-looking 200 over unordered rows. + A survey of framework, objectui and cloud found zero callers emitting a dotted + sort (objectui's column-header sort keys lookup columns by their flat field + name and loads relations via `$expand`), so the practical blast radius is + hand-authored requests — exactly the callers the silent degradation was + misleading. Internal callers reaching `engine.find()` directly are unaffected, + the same tiering every #4226 gate uses. + +- 5d21a48: feat(spec,metadata-protocol,metadata,objectql,service-automation): stored metadata replays the full conversion chain at rehydration (#3903) + + Every mechanism the platform has for evolving the metadata contract — schema + transforms, the ADR-0087 D2 conversion layer, the D3 migration chain, the + protocol-17 tombstones — operated on **authored source** only. Metadata **at + rest** (`sys_metadata` rows written by Studio or the runtime authoring APIs) + was rehydrated unparsed and unconverted, so the authored and stored contracts + silently diverged: a pre-17 row carrying `conditionalRequired` or `execute` + read as whatever each ad-hoc consumer happened to do with it. + + **New spec primitive — `applyConversionsToStoredItem(type, item, options?)`** + (exported from the package root). Wraps one stored item of a given metadata + type and replays the **full** conversion chain over it — `retiredFromLoadPath` + entries included, because retirement is an _authoring-surface_ event: the + window exists to teach a live author, and a row at rest has no author to + teach. Idempotent, never throws, never validates. + + Wired at every stored-row rehydration seam: + + - `metadata-protocol`: `loadMetaFromDb`, `getMetaItems` (active + draft + preview), `getMetaItem` (active + draft), `getMetaItemLayered`, and + `duplicatePackage` (a copy re-saves through the schema gate, so legacy + sources now duplicate successfully — and the copy is canonical). + - `metadata`: the DatabaseLoader's live-row reads (`load` / `loadMany`). + History reads stay verbatim — history records what was written. + - `objectql`: the authored-action / authored-hook direct table reads, so + runtime-authored actions stored with the removed `execute` alias dispatch + via `target` again. + - `service-automation`: `AutomationEngine.registerFlow` now passes + `includeRetired` — stored flows keep canonicalizing after their conversions + graduate out of the load window. (The generic metadata seams deliberately + skip `type: 'flow'`: flow conversions carry the open-namespace conflict + guard, which needs this engine's live executor registry.) + + **Boot hydration diagnoses instead of shrugging.** `loadMetaFromDb` now + returns `{ loaded, errors, invalid }`: each row is validated against its + type's spec schema _after_ conversion, and a genuine contract violation is + counted and warned with a stable `[metadata_spec_invalid]` marker — but still + registered, deliberately: refusing at boot would unhook live tables and make + the row unlistable and unfixable in Studio. The write path (`saveMetaItem` → 422) and the read-side `_diagnostics` envelope remain the enforcing gates; the + `SchemaRegistry.registerItem` validation hook is now documented as exactly + that diagnostic. + + **Retired accommodation.** With the chain running on every stored read path, + the rule-validator's `requiredWhen ?? conditionalRequired` fallback — kept in + #3883 with a retirement promise that had no mechanism — is deleted. If you + call `evaluateValidationRules` directly with raw legacy field definitions, + convert them first (`applyConversionsToStoredItem('object', def)`) or author + `requiredWhen`; the platform's own read paths already hand you canonical + shapes. + +- 3245174: fix(metadata-protocol): read decorations stop round-tripping into persisted metadata bodies (#4326) + + `getMetaItem` / `getMetaItems` decorate every served document with + `_diagnostics` (and `_draft` on preview reads), while the write path persists + the request body **verbatim** by design (ADR-0005 §Validation — `parsed.data` + would strip Studio-only auxiliary fields). Nothing stripped the decorations in + between, so the standard designer round-trip — GET the served document, edit a + field, PUT the whole body back — baked a stale read-time verdict into + `sys_metadata.metadata`, into its checksum, and into every history diff. + + It was never user-visible: reads recompute `_diagnostics` and the fresh verdict + shadows the persisted one. What it corrupted was the stored bytes — a + decoration-only re-save moved the content checksum, and history diffs carried + diagnostic noise no author wrote. + + `saveMetaItem` now strips `_diagnostics` and `_draft` from the body before the + destructive-change diff, the schema gate, the authoring gate, and persistence + (new `stripReadDecorations`, exported for tests). A **silent** strip, unlike the + neighbouring layered-envelope rejection: those keys are our own decoration + riding on a document that is otherwise exactly what the author edited, so + rejecting the round-trip would be hostile. The ADR-0010 protection envelope + (`_lock`, `_lockReason`, `_provenance`) and `_packageId` are deliberately left + alone — envelope state the write path legitimately carries, not read decoration. + + Also documents the #3903 conversion boundary on `SysMetadataRepository.get`: + its body stays verbatim because every caller wants the bytes a hash was + computed over (parent-version lineage, existence probes) or is diffing against + equally-verbatim history rows — conversion belongs one layer up, at the + protocol's serving seams. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 144a1d19cb..94ed232497 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 6800154162..11e5cbb999 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,579 @@ # @objectstack/metadata +## 17.0.0-rc.1 + +### Major Changes + +- ac6c0be: refactor(metadata)!: remove the `artifact-api` artifact source (#4246) + + `MetadataPluginOptions.artifactSource` loses its `artifact-api` union member; + `{ mode: 'local-file', path }` is now the single artifact source. The + `_loadFromArtifactApi` loader, its `environmentId` pre-flight guard, and the + Bearer-token support in `_fetchJson` go with it. + + **Why removal, not the doc fix this branch first carried.** #4246 found the + declaration and the implementation contradicting each other — the option's + comment called `artifact-api` "reserved for M3/M4" while the loader shipped and + all three bootstrap modes dispatched to it — and asked the owner to pick a + direction. Auditing both repos to answer that settled it: + + - **Zero consumers anywhere.** No `mode: 'artifact-api'` call site exists in + this repo or in cloud. The two real "pull an artifact from the cloud" paths + both bypass it: the cloud runtime uses its own `ArtifactApiClient` (TTL + cache, singleflight, hostname resolution, runtime config injection — a + superset this option was never going to grow into), and package distribution + into a running OSS instance goes through `@objectstack/cloud-connection` + (`os package install`, ADR-0008). + - **Half its input contract had been dead since v5.0 with no one noticing.** + The URL builder decided "append the canonical path vs use as-is" by testing + for an `/api/v{n}/cloud/projects/` segment that the v5.0 + `project → environment` rename deleted, so every already-resolved URL got + the path appended a second time and 404'd. A year of silence on a bug like + that is consumer-count evidence of its own. + - **Its one non-replaceable capability was declined.** A Bearer-authenticated + pull of a _private_ environment artifact is the single thing `local-file` + cannot do (`local-file` URLs fetch verbatim, unauthenticated). The owner + confirmed that sealed-private-artifact deployments are not a supported need + right now, which removed the last reason to keep the mode. + + **Migration.** Public or commit-pinned artifacts load through the existing + `local-file` URL form, which every bootstrap mode already honors: + + ```ts + artifactSource: { + mode: 'local-file', + path: 'https://cloud.example.com/pub/v1/environments/env_42/artifact?commit=cmt_1a2b', + } + ``` + + (`private` environments still serve exact-commit deep links through the same + `/pub` route; fully private pulls have no replacement — by decision, not + oversight.) For installing packages into a running runtime, use + `os package install` / `@objectstack/cloud-connection`. + + **The removal is loud, not silent.** A still-configured `artifact-api` source + (reachable from JS or `any`-typed config now that the TS union is + single-member) throws at `start()` with the migration pointer above. This + guard exists because the dispatch's old fall-through would have treated + "unsupported source" as "no source" — under `eager` that silently scans the + filesystem instead of loading the artifact the caller named. Tests pin the + rejection in `artifact-only` and `eager`, and pin the migration target + (`local-file` fetching an http(s) URL and registering the envelope) so the + path the error message points at stays real. + + Also replaces a test that passed for the wrong reason: "artifact-only + bootstrap rejects the not-yet-implemented artifact-api source" matched + `/artifact-api/` against the missing-`environmentId` guard's message — which + merely contained the string — proving nothing about implementation status. + The doc comment, `implementation-status.mdx`, `metadata-service.mdx`, and the + package ROADMAP now all describe the single `local-file` source, ending the + docs-audit loop #4246 was filed to stop. + +### Minor Changes + +- ffb003c: **ADR-0110 — an action's identity is its `name`, and anything executable over a + governed surface must have a declaration.** + + `POST /api/v1/actions/:object/:action` resolved the DECLARATION from the URL + segment as a `name` but dispatched the HANDLER using that same segment as a + registry key. For a target-bound action (`{ name: 'complete_task', target: +'completeTask' }`) those are different strings, so the two documented callers + each worked on exactly the half the other broke: the documented curl resolved + the declaration then 404ed, while the Console's `target`-addressed call + dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4 + capability gate and the ADR-0104 param contract (#3935). + + - **D1/D2** — identity is always the declarative `name`; the handler key is + derived from the resolved declaration through a rotation now shared with the + MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`). + The REST route previously rotated only the object key, never the handler key. + - **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely + undeclared handler is **refused (404)** with the `defineAction` to add, rather + than executed ungated with system privileges; an unreachable metadata plane is + a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells + a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration + valve — it warns on every invocation and is removed in 18. + - **D5** — `reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions` + power a `kernel:ready` inventory logging every registered-but-undeclared + handler (refused at dispatch) and every declared script action bound to no + handler — the ADR-0078 converse, mechanised. + - **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in. + + Apps whose actions are all declared need no changes beyond gaining enforcement + of the `requiredPermissions` they already declared. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- f985b3f: fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + + **`packages/core/src/contracts/` was a dead near-copy of the real contracts, + and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had + ZERO importers — no relative import, no subpath export, not a tsup entry; + core's barrel has re-exported the `@objectstack/spec/contracts` versions all + along ("Re-export contracts from @objectstack/spec for backward + compatibility"). But the shadow had already **diverged** from the live + contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew + `rawBody?`; the copy never did), so anyone who grepped their way into it read a + stale contract that nothing enforces — the exact both-humans-and-AI failure + mode behind the false `http.server` exemption (#4382). Deleting it is + zero-risk by construction: nothing could reach it. + + **`http.server` is the canonical slot name, and the ledger now says so.** + `ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated + `'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin + register both two lines apart; cloud's two server entrypoints do the same). + Canonical is the only name present on EVERY provider path: runtime's + `config.server` path registers no alias, so the three cloud-connection plugins + that read the alias alone (marketplace-proxy, runtime-config, + marketplace-install-local) found an empty slot there — a live miss, now fixed: + all readers go canonical-first with the alias as a fallback that dies with the + alias registrations. The registrations themselves are untouched this release; + both sites now carry the deprecation note. + + **`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle + escape, declared once. Four consumers were each declaring it locally + (cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); + those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` + return is deliberate and documented at the single declaration: the handle's + real type belongs to the framework, and naming it would give the contract a + framework dependency. Adapters are not required to expose it; consumers + feature-detect. + + **`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options + their implementation has always accepted.** `bulkRegister`'s contract options + dropped the `MetadataWriteOptions` half its implementation intersects in + (`notify` is destructured on the method's first line); `bulkUnregister` + declared no options at all while the manager takes them. Same shape as the + `IDataEngine` read-methods gap from B2: a caller typed to the contract could + not reach the channel without erasing the lookup. Both additive; no implementor + or caller breaks. + + Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup + typed while touched). + +- 10575f3: fix(lint,metadata): revoke the `http.server` lint exemption — its stated reason was false (#4251) + + `http.server` was added to `UNCONTRACTED_SLOTS` in #4321 on the ground that + "no IHttpServer contract exists". The contract does exist — + `packages/spec/src/contracts/http-server.ts` — and eight call sites were + already resolving the slot as `getService(…)` when the exemption + was written. An exemption is a claim like any other, and this one rested on a + premise nobody checked: the same shape as the gaps the rule exists to find. + + Revoked. That surfaced **9 erasures the exemption had been hiding** — 7 in + files never grandfathered, 2 as count growth inside grandfathered ones, none of + which the baseline could legally absorb. All typed to `IHttpServer`; + `packages/metadata/src/plugin.ts` came out clean entirely, so the baseline + ratchets **DOWN to 168 sites in 36 files** and loses a file. + + Two things confirmed on the way, reported rather than changed: + + **`http.server` and `http-server` are the same instance under two names.** + plugin-hono-server and qa's node-plugin each register it twice, two lines + apart; runtime's `config.server` path registers only `http.server`. + `metadata/src/plugin.ts` reads both with a `??`, which is how it survived. No + registration is removed here — that is a runtime-behaviour change and belongs + with whoever picks the canonical name. + + **`IHttpServer` is defined twice and the two have already diverged.** + `packages/spec/src/contracts/http-server.ts` (15 importers) declares `write?()` + and `end?()`; `packages/core/src/contracts/http-server.ts` (8 importers) does + not. Spec's is the superset and the one the ledger points at, so it is the + source; core's is a stale near-copy and should re-export it. Left for its own + change — collapsing a duplicated contract is not a lint fix. + + Also worth a note for whoever writes the wider HTTP contract: `getRawApp()` now + has a **third** independent consumer (metadata's HMR routes, joining + cloud-connection's two). It is deliberately absent from `IHttpServer` — the + contract is framework-agnostic and the raw app is the framework's own handle — + so each consumer names it locally. Three is enough evidence to decide whether + that stays the right answer. + +- d13004a: feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + + `kernel.use()` registration order was never a contract — the kernel resolves + init/start order from the plugin dependency graph — but a plugin that needed a + service at init _when its provider is composed_ while also booting _without_ + the provider had no way to declare that. `AppPlugin` was the standing example: + it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing + (a hard dependency would break empty-env / metadata-only / mock-engine + kernels), and so its correctness rode on which array slot each caller put it + in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s + first cut; then #4085, disguised for months as "crashes when the artifact is + missing"). + + The kernel `Plugin` contract gains three additive fields, enforced by both + `ObjectKernel` and `LiteKernel` through one shared implementation + (`plugin-order.ts` — the previously duplicated topological sort is unified + there): + + - **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. + - **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. + - **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + + Plugins that declare nothing get the diagnosis too: a `getService` miss + during Phase 1 now appends which plugin was initializing and — when a + composed plugin declares the service — who provides it and how to declare the + ordering. The `Service '' not found` prefix and the factory-backed + `is async - use await` message are unchanged. + + First adopters: `AppPlugin` declares + `optionalDependencies: ['com.objectstack.engine.objectql']` + + `requiresServices: ['manifest']` (cleared on the empty-env no-op path), so + the #4085 composition — AppPlugin registered before the engine — now boots + correctly in every slot; `ObjectQLPlugin` declares + `providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and + `MetadataPlugin` declares `providesServices: ['metadata']`. + + Everything is additive — plugins that declare nothing keep their exact + ordering semantics; no existing declaration changes meaning. + +- 857a6cf: fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085) + + The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is + a development platform, so it has to start without one — but `os serve +objectstack.config.ts` died during boot whenever the artifact was absent: + + ``` + Loading objectstack.config.ts... + [StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT… + + ✗ Service 'manifest' is async - use await + ``` + + Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with + only its `dist/objectstack.json` moved aside), and on every freshly authored + project between `os init` and its first `os compile`. The message named neither + the missing artifact nor a fix, so it read as an internal kernel fault. + + Three separate faults, each of which alone was enough to refuse the boot: + + - **`serve` registered the config-derived `AppPlugin` before the stack's own + `plugins[]`.** Registration order _is_ the kernel's init/start order, and that + slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and + `DefaultDatasourcePlugin` (which connects the database the app seeds through). + The wrap is now **appended** to `plugins[]`, the same slot + `createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot + and artifact-boot share one plugin order. The artifact path never hit this, + which is exactly what made a plugin-**order** bug look artifact-related. + + - **`ctx.getService()` reported a never-registered service as "is async".** + `PluginLoader.getService` is an `async` method, so its return value is _always_ + a Promise and its internal "not found" rejection can never surface + synchronously — the kernel read the answer off that Promise and told every + caller to `await` a service that did not exist, while the `not found` branch + below it was unreachable. It now decides from the registry: absent ⇒ + `[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged + `Service 'x' is async - use await`. The same crash now reads + `[Kernel] Service 'manifest' not found`, which points at the layer that is + actually wrong. + + - **`MetadataPlugin` treated an absent `local-file` artifact as fatal.** + `createStandaloneStack` always points it at `dist/objectstack.json`, so a stack + with no app at all could not boot. A **missing** local artifact is now "nothing + compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so + a later `os compile` hydrates the running server. The tolerance is + ENOENT-only — a malformed or unreadable artifact stays fatal — and + `bootstrap: 'artifact-only'` (sealed runtime, where the artifact _is_ the + deployment) keeps failing loudly rather than silently serving an empty runtime. + + `[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted + at callers for whom "no artifact" is a healthy state; a present-but-unusable + artifact keeps the loud warning. + + Pinned by an e2e pair that drives the real `os serve` with **no `os compile` + anywhere**: an app defined only by `objectstack.config.ts` (asserting its object + is in the started plugin set, not merely that boot survived) and a bare + `export default {}` platform. The #4012 fixture drops the `os compile` this bug + had forced on it. + +- 5d21a48: feat(spec,metadata-protocol,metadata,objectql,service-automation): stored metadata replays the full conversion chain at rehydration (#3903) + + Every mechanism the platform has for evolving the metadata contract — schema + transforms, the ADR-0087 D2 conversion layer, the D3 migration chain, the + protocol-17 tombstones — operated on **authored source** only. Metadata **at + rest** (`sys_metadata` rows written by Studio or the runtime authoring APIs) + was rehydrated unparsed and unconverted, so the authored and stored contracts + silently diverged: a pre-17 row carrying `conditionalRequired` or `execute` + read as whatever each ad-hoc consumer happened to do with it. + + **New spec primitive — `applyConversionsToStoredItem(type, item, options?)`** + (exported from the package root). Wraps one stored item of a given metadata + type and replays the **full** conversion chain over it — `retiredFromLoadPath` + entries included, because retirement is an _authoring-surface_ event: the + window exists to teach a live author, and a row at rest has no author to + teach. Idempotent, never throws, never validates. + + Wired at every stored-row rehydration seam: + + - `metadata-protocol`: `loadMetaFromDb`, `getMetaItems` (active + draft + preview), `getMetaItem` (active + draft), `getMetaItemLayered`, and + `duplicatePackage` (a copy re-saves through the schema gate, so legacy + sources now duplicate successfully — and the copy is canonical). + - `metadata`: the DatabaseLoader's live-row reads (`load` / `loadMany`). + History reads stay verbatim — history records what was written. + - `objectql`: the authored-action / authored-hook direct table reads, so + runtime-authored actions stored with the removed `execute` alias dispatch + via `target` again. + - `service-automation`: `AutomationEngine.registerFlow` now passes + `includeRetired` — stored flows keep canonicalizing after their conversions + graduate out of the load window. (The generic metadata seams deliberately + skip `type: 'flow'`: flow conversions carry the open-namespace conflict + guard, which needs this engine's live executor registry.) + + **Boot hydration diagnoses instead of shrugging.** `loadMetaFromDb` now + returns `{ loaded, errors, invalid }`: each row is validated against its + type's spec schema _after_ conversion, and a genuine contract violation is + counted and warned with a stable `[metadata_spec_invalid]` marker — but still + registered, deliberately: refusing at boot would unhook live tables and make + the row unlistable and unfixable in Studio. The write path (`saveMetaItem` → 422) and the read-side `_diagnostics` envelope remain the enforcing gates; the + `SchemaRegistry.registerItem` validation hook is now documented as exactly + that diagnostic. + + **Retired accommodation.** With the chain running on every stored read path, + the rule-validator's `requiredWhen ?? conditionalRequired` fallback — kept in + #3883 with a retirement promise that had no mechanism — is deleted. If you + call `evaluateValidationRules` directly with raw legacy field definitions, + convert them first (`applyConversionsToStoredItem('object', def)`) or author + `requiredWhen`; the platform's own read paths already hand you canonical + shapes. + +- 7309c81: test(runtime,client,metadata): back the remaining suites with in-memory SQLite instead of the mingo driver (#4065) + + Ten test files used `InMemoryDriver` as a convenience backing store — somewhere + for rows to go while the suite proved something else (REST routing, datasource + auto-connect, the batch `$ref` contract, metadata history). They now run on + `SqliteWasmDriver` at `:memory:`, the same engine `@objectstack/verify`'s + `bootStack` already gives the dogfood gate: pure JS (no native build, CI-safe on + any runner) and real SQL semantics. + + The point is fidelity, not tidiness. Production runs SQL, and mingo differs from + it in ways that let a suite pass while the behaviour it stands for is broken. + Every failure this migration produced was a fixture defect the memory driver had + been absorbing: + + - **Tables were never created.** `driver.create()` on the memory driver is a + bare `table.push()` onto an auto-vivified array, so an object registered + _after_ `kernel.bootstrap()` — which misses the boot-time schema sync — looked + fine. On SQL the first write fails with `no such table`, which the REST error + mapper turns into a **404 `OBJECT_NOT_FOUND`**: a routing-shaped symptom for a + DDL-shaped cause. Four suites needed an explicit `syncObjectSchema`. + - **A missing object declaration read as working.** `notifications.hono.integration` + writes `sys_notification`, which `MessagingServicePlugin` does not declare — + it is a platform object, and that lean kernel never booted `platform-objects`. + Auto-vivification hid the omission entirely. The suite now registers the real + `SysNotification` rather than a hand-copied stand-in, so there is still exactly + one schema for it (Prime Directive #12). + - **`connect()` was optional.** The memory driver needs none; a SQL driver does. + + What deliberately did NOT move: `read-coercion-conformance` keeps its two-driver + matrix (proving a stored value reads back as its declared type on _both_ engines + is the entire point of that gate), and the suites whose subject IS the memory + driver or its wiring — `standalone-stack` (`memory://` scheme), + `sqlite-driver-fallback` (the dev step-down), the CLI's driver-label tests, and + driver-memory's own suite. + + `datasource-autoconnect` is in that second group as of #4083, which landed a + regression test there for exactly the memory-pool property this PR originally + proposed to migrate away from. Moving that file to SQLite would have left the + new test passing vacuously — a wasm-SQLite pool never writes `.objectstack/` at + all — so it stays on the memory driver and keeps guarding what it was written + to guard. + + No new coverage is claimed here: each suite asserts exactly what it asserted + before, against a more faithful store. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + - @objectstack/metadata-fs@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 5f1c3d1b0b..addfe851c8 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 2489b85988..303582e03b 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1352 @@ # @objectstack/objectql +## 17.0.0-rc.1 + +### Major Changes + +- 2d3e255: feat!: ADR-0113 — `required` is a write contract; the column constraint becomes the explicit `storage.notNull` + + `field.required` bound three meanings to one knob (write check, `NOT NULL` DDL, + drift expectation), so tightening any invariant on a deployed object was a + destructive migration blocked by the very legacy nulls that motivated it — the + reason `criteria_json`'s mandatory-in-substance contract lived in three + imperative guards instead of one declaration. + + Split, with the **non-regression invariant** as the unifying rule — _a write + may not take a record from compliant to violating; a pre-existing violation + does not block writes that leave it in place_: + + - `required: true` = the write contract, uniformly on new and deployed objects: + insert must provide; **an update PATCHing `null` into a required field is now + rejected** (it silently passed before); omitted fields never block, so legacy + null rows rest. The column stays nullable. + - `storage: { notNull: true }` = the explicit physical constraint, owning the + DDL (`sql-driver` `createColumn`) and the destructive drift ceremony. + Orthogonal to `required` — all four combinations are legitimate, including + the engine-populated column (`storage.notNull` without `required`). + - `requiredWhen` inherits the same invariant: flipping the condition true + without providing the field is rejected (the write _creates_ the violation); + a row violating since before the rule tightened no longer locks out + unrelated edits (#3929's objection, cured). `storage.notNull` × + `requiredWhen` rejects at parse (`FieldSchema.superRefine`). + - **Pre-17 sources keep their exact meaning** via the migration-chain-only + `field-required-notnull-explicit` conversion: `os migrate meta` stamps + `storage.notNull` onto every previously-required field — writing down what + the old text already meant. The loader never infers semantics from the + physical column. + - Drift compares nullability against `storage.notNull`; a column stricter than + its declaration is `needs_confirm` (never auto-applied — dev auto-reconcile + no longer silently strips a stray `NOT NULL`), and silent when the field is + write-gated by `required`. + +- 55bbefc: fix(objectql)!: retire the dead `ObjectQLEngine.use()` plugin path (#4212 follow-up) + + `ObjectQLEngine.use(manifestPart, runtimePart)` was the engine's own plugin + loader: register a manifest, then dispatch the runtime part's `onEnable` with + an `ObjectQLHostContext`. **Nothing calls it** — not the kernel (plugins go + through `kernel.use()` → `init`/`start`), not the CLI, not a test, not an + example, repo-wide. Its `onEnable` dispatch is the engine-level twin of the + #4212 disease: a lifecycle entry point that reads as a contract and never + runs. The _app-bundle_ `onEnable` module export is a different, real contract + (dispatched by AppPlugin at boot) and is unchanged. + + Removed: + + - `ObjectQLEngine.use()`. + - `ObjectQLHostContext` (exported from `@objectstack/objectql` and + `@objectstack/objectql/core`) — constructed only inside the dead method. + - The engine's private `hostContext` field — its only read outside the dead + method was the constructor's `logger` extraction, which stays; the + constructor signature is unchanged (`new ObjectQL({ logger })` keeps + working, as does `ObjectQLPlugin`'s `hostContext` option that feeds it). + + FROM → TO: + + - `engine.use(manifest)` → `engine.registerApp(manifest)` (the alive half — + the manifest service and ObjectQLPlugin already route through it). + - `engine.use(_, { onEnable })` → a kernel plugin: `kernel.use({ name, +init(ctx) { … } })`; the engine is `ctx.getService('objectql')`, drivers + register via `engine.registerDriver()`. + - `ObjectQLHostContext` → no replacement; the type described the context of + a hook that never fired. + +- 77fadbf: fix(metadata-protocol,objectql)!: retire the degraded analytics shim — the `analytics` slot stays empty without service-analytics (#3891, #3878) + + The protocol assembly (`assembleMetadataProtocol`, used by both + `MetadataProtocolPlugin` and `ObjectQLPlugin`'s built-in mode) used to register + a lightweight `analytics` fallback so `POST /api/v1/analytics/query` kept + answering on installs without `@objectstack/service-analytics`. That fallback + is **removed**, and with it the facade methods that existed only to serve it: + `ObjectStackProtocolImplementation.analyticsQuery` / `getAnalyticsMeta` (the + class no longer implements `AnalyticsProtocol`). + + Why removal instead of repair (#3891): + + - **It dropped the caller's ExecutionContext at the door.** The dispatcher + passes `context.executionContext` (#2852), but the shim's `query` was + unary — aggregation reached `engine.aggregate` with no context, the security + middleware's empty-principal branch waved it through, and **no RLS or tenant + predicate was injected**. An authenticated caller got a 200 with rows RLS + would hide. + - **It ignored the contract filter.** `AnalyticsQuery`'s canonical filter field + is `where`; the shim read only a non-contract `filters` key, so a + spec-conformant filtered request silently returned a full-table aggregate. + - **Every security gate had to be built twice** (#3770 on the shim vs + #3867/#3875 on the real engine) — the "duplicates logic only, harmless" + assessment in ADR-0076 D10 did not survive contact with reality. + + `getDiscovery()` stops hardcoding analytics as an always-on kernel service — + the entry is now computed from the service registry like every other optional + service (`enabled: false, status: 'unavailable'` and **no advertised route** + when absent), which also removes the pre-#2462 discovery lie the shim was + originally invented to make true. + + **Migration.** Deployments that relied on the fallback (programmatic + `createStandaloneStack()` / `createObjectQLKernel()` embeds, hosts whose bundle + doesn't require `analytics`): install `@objectstack/service-analytics` and + mount `AnalyticsServicePlugin` — the real, context-aware engine. Without it, + `/api/v1/analytics/*` now answers **404 ROUTE_NOT_FOUND** (previously: 200 with + unscoped, unfiltered aggregates) and discovery reports + `analytics: { enabled: false, status: 'unavailable' }`. Callers of + `protocol.analyticsQuery(...)` / `protocol.getAnalyticsMeta(...)` must use the + `analytics` service (`kernel.getService('analytics')`) instead. `os serve` + default/full presets and managed environments already force the real engine and + are unaffected. + +### Minor Changes + +- 48fcf70: **[ADR-0110 D5] The action-governance inventory moves to the engine plugin — + AppPlugin never ran it on the platform's own dev path.** + + Dogfooding the inventory with a positive control (an injected undeclared + handler) showed the `kernel:ready` hook it hung on never fired under `os dev`: + AppPlugin is registered conditionally (`serve.ts` skips it when the host wraps + itself; the dev fast path loads apps without it), so the checklist that + justifies D3's no-opt-out refusal was never printed where an upgrade most + needs it. + + - The addressing vocabulary (`GLOBAL_ACTION_OBJECT_KEY`, + `actionHandlerObjectKeys`, `isObjectLessActionKey`, + `resolveActionHandlerKeys`) and the reconciliation move into + `@objectstack/objectql` — the engine owns the map they describe, and the + dependency direction (runtime → objectql) permits no other home. + `@objectstack/runtime` re-exports them unchanged, so dispatch, the MCP + bridge and existing importers keep reading ONE implementation. + - `ObjectQLPlugin` now runs the inventory in its existing `kernel:ready` + handler — after `resyncAuthoredActions`, so the audited registry is final — + and again on `metadata:reloaded`, fingerprint-suppressed so a reload that + changed nothing action-related logs nothing. A Studio edit that orphans or + binds a handler updates the report live; the old boot-only snapshot went + stale on the first edit. + - Verified end-to-end with a programmatic kernel: the injected orphan is + named, a clean registry is silent. The `os dev` / `os serve` consoles still + swallow ALL plugin boot logs (pre-existing, tracked separately) — on those + surfaces the inventory becomes visible once that sink is fixed. + +- b1863a5: feat(objectql): `engine.isFileReferencesMigrationVerified()` is public — one memoized flag read for both in-process consumers (#3459 PR-5b) + + The memoized per-deployment read of the `adr-0104-file-references` migration + flag was private to the engine's media value-shape enforcement. The storage + service's release path now asks the same question — may a released field file + be tombstoned? — so the method is public and the release hooks reach it as an + optional duck-typed member (an older engine or a test fake reads as "not + verified", failing closed). One read, one invalidation + (`invalidateDataMigrationFlags()`), no way for the two consumers to see + different answers. + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +- 3aef718: feat(migrate): `os migrate value-shapes` — the per-deployment gate for reference and structured-JSON value shapes (#3438) + + The second of ADR-0104 D1's two evidence gates. Media value shapes already + enforce once a deployment has verified its file migration (#3681); the + reference (`lookup` / `master_detail` / `user` / `tree`) and structured-JSON + (`location` / `address` / `composite` / `repeater` / `record` / `vector`) + classes now get a gate of their own. + + ```bash + os migrate value-shapes # scan: reports, writes nothing + os migrate value-shapes --apply # scan + record the deployment flag when clean + ``` + + The run walks every stored value of those classes against + `valueSchemaFor(field, 'stored')` — the same predicate the write path enforces, + imported rather than re-derived — and, at zero violations, records + `sys_migration { id: 'adr-0104-value-shapes', verified_at, blocking: 0 }`. + Strict enforcement of these classes reads **that row**, never the platform + version, so upgrading changes nothing until a deployment produces its own + evidence. + + **There is no backfill, deliberately.** The file migration converts legacy + values because the platform narrowed that storage form and owes the conversion. + A malformed `location` is application data whose correct value only its author + knows, so this run reports and prescribes — naming the object, field, type, + count, offending record ids and the parse issue — and the operator fixes and + re-runs. With nothing to convert, `--apply`'s only write is the flag row, which + keeps the #3617 invariant trivially: a dry run changes nothing, and whether a + run changed this deployment's posture never depends on what it found. + + **A separate flag from the file migration**, because it attests a separate + fact. That flag says file values were migrated and their ownership reconciled; + it says nothing about whether a `lookup` id or a `location` payload is well + formed. Gating these classes on it would be borrowing evidence for a fact it + does not cover. + + - New escape hatch **`OS_ALLOW_LAX_VALUE_SHAPES=1`** returns a verified + deployment to warnings, with the same precedence as its media sibling: the + opt-out beats `OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, which beats the flag. + Wrongly staying lenient costs a warning; wrongly enforcing stops a working + app from writing. + - `@objectstack/spec/system` exports `VALUE_SHAPES_MIGRATION_ID`. + - `@objectstack/objectql` exports `scanValueShapes`, `valueShapeScanPassed` + and `formatValueShapeScanReport`. The scanner is read-only and does **not** + record the flag: readers of a migration flag use the spec contract, only + writers depend on `@objectstack/platform-objects`, so the composition lives + with the CLI command rather than inverting the engine's dependencies. + - `validateRecord` gains `valueShapeStrict`, the sibling of + `mediaValueShapeStrict`. Both default to `false`: a caller that cannot say + stays lenient, so nothing starts rejecting merely because the evidence was + unavailable. + + **Nothing changes for an existing deployment until it runs the command.** A + scan that is truncated, or that cannot read an object, fails the gate even with + zero violations found — "none in the part we read" is not the claim the flag + makes. + +- ffb003c: **ADR-0110 — an action's identity is its `name`, and anything executable over a + governed surface must have a declaration.** + + `POST /api/v1/actions/:object/:action` resolved the DECLARATION from the URL + segment as a `name` but dispatched the HANDLER using that same segment as a + registry key. For a target-bound action (`{ name: 'complete_task', target: +'completeTask' }`) those are different strings, so the two documented callers + each worked on exactly the half the other broke: the documented curl resolved + the declaration then 404ed, while the Console's `target`-addressed call + dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4 + capability gate and the ADR-0104 param contract (#3935). + + - **D1/D2** — identity is always the declarative `name`; the handler key is + derived from the resolved declaration through a rotation now shared with the + MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`). + The REST route previously rotated only the object key, never the handler key. + - **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely + undeclared handler is **refused (404)** with the `defineAction` to add, rather + than executed ungated with system privileges; an unreachable metadata plane is + a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells + a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration + valve — it warns on every invocation and is removed in 18. + - **D5** — `reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions` + power a `kernel:ready` inventory logging every registered-but-undeclared + handler (refused at dispatch) and every declared script action bound to no + handler — the ADR-0078 converse, mechanised. + - **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in. + + Apps whose actions are all declared need no changes beyond gaining enforcement + of the `requiredPermissions` they already declared. + +- 7d7521f: feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) + + Settles the vocabulary ADR-0112 D6 deferred, per [ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md). + + **`FieldErrorCode` — a closed, lowercase catalog.** 27 members covering what the + six emitters already emit. `FieldErrorSchema.code` tightens from `z.string()` to + this enum, so a validation body's per-field codes are validated for the first time. + `FieldValidationError.code` (objectql) and `FieldCoerceError.code` (rest) stop + being a hand-listed union and a bare `string` respectively and reference the + catalog, so the three cannot drift apart. + + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a + top-level code names the condition the _request_ hit, while a field-level code + names the _constraint_ the value violated — and constraints are declared in the + metadata's own snake_case, so `max_length` the code and `max_length: 50` the + property are the same word on purpose. + + **Zod issue codes no longer reach the wire (wire-visible).** Routes that validate + with Zod passed its vocabulary straight through, so `fields[]` spoke a different + language depending on which route served it, and `too_small` was ambiguous between + a short string, a small number and a short array. `zodIssuesToFields` now maps + using Zod's `origin`/`format`: + + | Was | Now | + | :------------------------------------------------ | :------------------------------------------------- | + | `too_small` | `min_length` / `min_value` / `min_items` | + | `too_big` | `max_length` / `max_value` / `max_items` | + | `invalid_format` | `invalid_email` / `invalid_url` / `invalid_format` | + | `invalid_value` | `invalid_option` | + | `unrecognized_keys` | `unknown_field` | + | `invalid_union`, `invalid_element`, `invalid_key` | `invalid_shape` | + + **A missing required property now reports `required`, not `invalid_type`.** Zod + spells "absent" as a type mismatch against `undefined`, so passing it through made + a form mark a _missing_ input as the wrong _type_. The two are indistinguishable on + the issue alone, so the mapper takes the parsed input as an optional argument and + walks the issue path; a caller that cannot supply it keeps `invalid_type` rather + than guessing. + + **`unknown_param` → `unknown_field`.** `ActionParamIssue.code` references the + catalog instead of its own literal union; the `param` key beside it already says + what was addressed. + + **Not changed:** `EnhancedApiErrorSchema.fieldErrors` keeps its name even though + every producer emits `fields`. Retiring an authorable key needs a tombstone plus a + migration (ADR-0104's contract guard), so it lands on its own — the property now + carries a banner saying which name the wire uses. + +- ab9fb5c: A hook with an empty `object` target is refused instead of silently widened to the wildcard. + + `HookSchema.object` had no emptiness constraint, so `''`, `[]` and `['']` all parsed. The binder's `normalizeObjects` then mapped the first two to `['*']` — the engine's match-everything sentinel — so a hook whose target was left blank registered on **every** object in the tenant, on every event it listed, with no diagnostic anywhere. `['']` failed the other way, registering on an object name nothing matches: a hook that could never fire (ADR-0078). Both shapes are now refused, at parse time and again in the binder (which accepts unparsed input, so the guard has to hold in both places). The error names the two spellings that work and the wildcard the blank silently became. A wildcard hook stays legitimate — it just has to be spelled `'*'`, so it is a choice visible in a diff. + + Also fixes `bindHooksToEngine`'s `strict` option, which is documented as "fail fast on misconfiguration" but never threw: the per-hook `try`/`catch` swallowed the throw its own strict branch raised, recording the failure twice and carrying on. Under `strict` a bind failure is now fatal, as advertised. + +- 507b92a: fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957) + + The write path built every built-in validation message by concatenating the **API + field name** into a **hardcoded English** template. Those strings are what the + Console toast, the CSV-import row report, the CLI and any custom client display + verbatim, so a Chinese-locale user importing a bad row read: + + ``` + 第 1 行:penalty_amount must be ≥ 0 + ``` + + …for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The + form layer localized the _same_ constraint correctly (the browser's native + `min`), so the language flipped depending on which layer caught the value. + + **Three things changed.** + + 1. **The message is rendered in the caller's locale** from a built-in catalog + (`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`, + `zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles. + The locale comes from `ExecutionContext.locale`, whose contract already read + "Drives message catalogs"; this is the consumer that makes that true. Both + HTTP entries (REST server, runtime dispatcher) now resolve it from the + request's `Accept-Language` / `?locale` first, falling back to the workspace + `localization.locale` — so a rejection message and the field labels around it + can no longer disagree. + + 2. **The field is named by its label, never the API name**: translation bundle + (`objects..fields..label`) → declared `label` → API name as the last + resort. `FieldValidationError.field` still carries the API name so a form can + focus the right input. + + 3. **The constraint is exposed as data**, so a client can format its own text + instead of parsing the sentence: + `{ field, code, message, label, constraint: { min: 0 } }`. This rides + ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema` + (`constraint` tightens from `unknown` to `Record`) rather + than adding a parallel payload — `label` is the only new field. The bag + carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the + message templates interpolate from exactly those keys. + + Covered end-to-end, not only in the validator: single and batch insert, + single-id and multi-row update, ADR-0113's clear-out rejection, the object-level + rule evaluator's own built-in messages (`requiredWhen`, per-option gating, + state-machine fallbacks), and the importer's cell-coercion, required pre-check + and #3956 bound pre-check messages — all of which land in the same row report. + + **What this changes for consumers.** + + - `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to + match on. Message keys are finer-grained than codes — `invalid_datetime`, + `invalid_option_value`, `required_cleared` are rendering detail and never reach + the wire — so localization never splits the client-facing vocabulary. + - `message` **text changes**: it is localized, and it names the field by label + even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything + asserting on the old English string should match `code` (and now + `constraint`) instead. + - An author-written validation-rule `message` is never touched — it is already + in the language its author chose. + - A deployment can override any built-in message with a `translation` item + defining `validation.field.` (e.g. + `validation.field.min_value: '{{label}}不得小于 {{min}} 元'`). + - The importer's reference-failure message no longer names the target object's + API name (`no sys_user matches "…"`): naming internal identifiers is the + defect being fixed, and the column plus the offending value are what an + importer can act on. + +- b09d8d9: feat(objectql)!: `query.having` is enforced — the engine applies it after aggregation (#4286 step 3, ADR-0049 resolved to enforce) + + `having` had been declared on the request surface since AST v2 and executed by + nothing. #4286 finding 1 showed the gap was structural: `engine.aggregate()` + rebuilt the driver AST with exactly `object`/`where`/`groupBy`/`aggregations`, + so even a driver that _did_ implement HAVING could never have received it, and + the one wire path (`findData`'s aggregate branch) dropped the clause too. It + was the strongest enforce candidate of the #4286 set — the clause every + SQL-literate author (human or model) expects to work next to + `groupBy`/`aggregations` — and it is now live end to end: + + - **Engine-owned, both paths.** `applyHaving()` + (`packages/objectql/src/having-filter.ts`) runs AFTER aggregation on the + native-driver path and the in-memory fallback alike — the same + correct-first / optimize-later two-tier shape date bucketing uses. Native + SQL `HAVING` pushdown can come later behind a driver capability flag without + changing semantics. + - **Namespace: the aggregated row's own columns** — aggregation aliases + (`order_count`, `total`) and groupBy projections — with the ordinary + FilterCondition operators plus `$and`/`$or`/`$not`. + - **An unknown operator rejects loudly.** Ignoring one (as tolerant matchers + do) would silently return unfiltered aggregates — the exact ADR-0078 + silently-inert failure enforcement exists to end. + - **The wire path forwards it.** `findData`'s aggregate branch passes + `having` through, and `EngineAggregateOptionsSchema` now declares it. + - The FLS predicate guard already walked `having` references + (`predicate-guard.ts`), which is what made enforcement safe to turn on. + + No migration needed: queries that carried `having` before were silently + returning every group; they now filter as written. A caller who depended on + the clause being _ignored_ (sending `having` and expecting unfiltered + results) sees the corrected behavior — that is the enforcement, not a + regression. + +- 5c13368: feat(objectql,runtime): the default-runner setters are first-wins, and the private-field probes that used to enforce that are gone (#4251) + + `setDefaultBodyRunner` / `setDefaultActionRunner` now enforce their own + documented contract — "the runtime layer sets this once per engine" — by + keeping the first runner and returning `false` for any later call. Public + accessors `getDefaultBodyRunner()` / `getDefaultActionRunner()` join them, and + the fields become real `private` members instead of `(this as any)` attachments. + + Before this, the invariant lived in the CALLERS: AppPlugin probed the engine's + private `_defaultBodyRunner` / `_defaultActionRunner` fields through `any` to + avoid clobbering another AppPlugin's runner on a shared kernel — an invariant + owned by every caller and enforced by none, and a private reach that a field + rename would have broken silently (the guard reads `undefined`, every AppPlugin + reinstalls). The engine's own `bindHooks` fallback and ObjectQLPlugin's + authored-action re-sync read the same fields the same way. All three read the + public accessors now; the only remaining `_default*` mentions in the repo are + comments and test doubles. + + Caller audit before the semantics change: every setter call site either owns a + fresh engine (the sandbox and hook-binder tests) or wants exactly + keep-the-first (AppPlugin) — nobody replaces a runner on a live engine. Return + type `void` → `boolean` is additive; AppPlugin uses it to keep its "Installed + default … runner" log truthful (skipped when the engine kept an earlier one). + + Pinned in hook-binder tests: second install refused end-to-end (the first + runner is the one that executes) and the accessors expose exactly what was + kept. + +### Patch Changes + +- 3ec8186: feat(migrate,objectql): the upgrade path names the data migrations that are still open here (#3438, ADR-0104 2026-07-30) + + Both value-shape gates fail toward leniency: a deployment that never runs its + migration keeps warning instead of rejecting, and keeps every released file + forever. That default is right — and completely silent, so the gate could sit + open for the life of a deployment without anyone learning that one command ends + it. A gate nobody is told about is served by nobody. + + Two announcements, each where an upgrade actually looks: + + - **`os migrate meta --from 16`** now ends by naming the data migrations a + chain crossing into 17 leaves behind — `files-to-references`, `value-shapes` + — with what each unlocks, scoped to the field classes the author's own + metadata declares (an app with no media field is never told about the file + migration). `--json` carries the same list as `dataMigrations`. The command + reads no database, so it reports what remains _to do_, never what a given + deployment has _done_. + - **The server logs one line per open gate at boot**, naming the command that + closes it. Only the lax posture announces itself — a verified gate already + logs that it is enforcing, and an app declaring neither class of field costs + nothing and says nothing. This is the half that can speak to a deployment's + actual data, because it is the half with the database. + + Nothing about enforcement changes: same gates, same flags, same fail-toward- + leniency default. The advisory runs on `kernel:bootstrapped` rather than + `kernel:ready`, deliberately — the answer depends on the storage service's own + ready handler, which registers `sys_migration` and may attest a store it just + created, and racing it would tell a brand-new deployment its gates are open + moments after they closed. + +- 956e7f9: fix(objectql): the boot gate announcement stops firing where it is false, and stops counting fields nothing enforces (#3438) + + The startup line that names an open value-shape gate (#4253) fired on every + deployment there is, and said something untrue on any deployment that had + already settled the question with an environment switch. Both are the same + failure — an advisory that speaks where it does not apply is how readers learn + to ignore it — and neither is reachable from the suite that shipped with it, + because `engine.test.ts` mocks the registry away and a mocked registry hands + the engine exactly the fields the test wrote. + + `objectHasCoveredValueField` — the dormancy short-circuit that is supposed to + spare an object with no covered field the flag query — tested raw type + membership, while the real registry INJECTS covered-type fields into every + object it registers: `organization_id` and `owner_id` (both `system`), + `created_by` and `updated_by` (both in `SKIP_FIELDS`), four `lookup`s. + `validateRecord` skips every one of them before it reaches the value-shape + check, so the short-circuit answered `true` for literally every object, never + fired, and its WeakMap memoized a constant. Counting is now by the validator's + own `isScannableValueShapeField`, the predicate the scanner already imports — + three readings of "a covered field" drifting by one clause is how a gate ends + up governing fields nothing enforces. + + The announcement also consulted no environment switch, while both postures it + reports on short-circuit ahead of the deployment flag. Under + `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` enforcement is already on for both + classes, so "checked but NOT enforced here" was simply false; under either + opt-out the operator chose leniency deliberately, so naming a migration that + cannot change what they get is noise. Each gate now consults its own pair + (`mediaPostureSetByEnv` / `valueShapePostureSetByEnv`, siblings for the reason + `mediaStrictEffective` and `valueShapeStrictEffective` are siblings), since the + opt-outs are per-class while the opt-in opens both. Cheapest test first, so a + kernel with nothing to say still reaches no flag query. + + Enforcement is unchanged: same gates, same flags, same default. The flag read + was already memoized per process, so what this corrects is the property + ADR-0104 states, not a user-visible cost. + +- 8d895ff: feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) + + Two more hand-copied lists retired the same way, each replaced by one spec + export and derivation at every consumer. + + **`AUDIT_PROVENANCE_FIELDS`** (`@objectstack/spec/data`, with the + `AuditProvenanceField` type) — the four columns `applySystemFields` injects on + every audit-tracked object: `created_at`, `created_by`, `updated_at`, + `updated_by`. That four-name list existed in at least four copies across two + repos: the registry's injection if-chain, the rule-validator's `preserveAudit` + allowlist ("Kept in sync with the registry's auto-injected audit fields" — by + nothing), and two objectui render surfaces. Now: + + - the registry's injection is table-driven, keyed by the tuple with a + `satisfies Record` clause — a name added to the spec + without a column definition (or vice versa) is a compile error, the + `APPROVER_VALUE_BINDINGS` discipline; + - the rule-validator's `AUDIT_TIMELINE_FIELDS` derives from the same tuple; + - `FIELD_GROUP_SYSTEM_FIELDS`' audit prefix derives from it too — one + declaration even inside the file that hosts both; + - objectui's `AUDIT_FIELD_BY_ROLE` already pins itself by subset assertion and + can import the tuple directly once this release is published. + + Injection behaviour is byte-identical — a conformance test pins every injected + column's shape against the pre-refactor definitions. + + **`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` / + `IMPORT_REFERENCE_TYPES`** (`@objectstack/spec/data`) — the `/import` coercion + vocabulary #4173 asked for. The server's `import-coerce.ts` now derives its + `BOOL_TRUE` / `BOOL_FALSE` / `REFERENCE_TYPES` from these instead of owning + them privately, and objectui's Import Wizard preview — which re-checks the same + contract client-side so a cell is flagged red exactly when the server would + reject it — can retire its pinned-inventory mirror once this release is + published (the retirement path is written in that file's own header). + `IMPORT_REFERENCE_TYPES` ships with the legacy `'reference'` spelling included, + retiring the `+ 'reference'` literal both ends carried separately. The tables' + own discipline is tested: sets disjoint, every token pre-normalized + (lower-case, trimmed), and the Chinese / check-mark spreadsheet-reality tokens + pinned by name. + + No behaviour change anywhere: every derived value is byte-identical to the + literal it replaces. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- c20b875: **Correct the stale premise left behind by #4012: the degraded-boot stderr copy + survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.** + + `emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to + stderr in addition to `logger.warn`, and every comment and test name explaining + why cited the same reason: `os serve` swallowed all of stdout while the kernel + booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window + now buffers and replays `warn`-and-above — which retires the _stated_ + justification for a duplicate that is nonetheless still load-bearing: + + `Logger.write()` returns before touching a stream when the record is below + `config.level`, so at `--log-level error`, `fatal` or `silent` the banner's + `logger.warn` reaches **no** stream at all. A production host at `error` is + exactly the deployment this escape hatch exists for, and exactly where a + logger-only banner would vanish. Removing the stderr copy on the strength of + #4012 would therefore have been a regression — so this documents the reason that + is still true, in the places someone would read before deleting it: + `degraded-boot.ts`, the engine's emit site, and all three parity tests + (objectql, runtime, service-datasource), which are renamed off "which `os serve` + boot-quiet cannot swallow" to "which the operator log level cannot filter away". + + The objectql parity test now proves the claim instead of asserting around it: it + drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on + stderr _and_ nothing on stdout. Set the level to `warn` and it fails — so the + test is pinned to the level filter rather than passing for any reason. + + Also corrected in the same sweep, all comment-only, all previously overstating + what #4012 had not yet fixed: + + - the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the + boot window swallowed the engine's binding warnings. Its real justification is + stronger and unchanged: a flow that silently fails to arm emits **no** log line + at any level, so binding state has to be read off the live engine — absence of + a warning was never evidence of a bound flow. + - the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s + seed-outcome note attributed the silence to the boot window; the operative + gate is that `SeedLoader`'s result logs are `info`, under the default `warn`. + + No behavior changes. + +- ed77493: fix(objectql,spec): `filter` folds to `where` on EVERY engine method, and `top`/`limit` joins the #3795 slot table (#4346) + + The `filter` → `where` fold that #3795 settled at the protocol layer existed + at the **engine** layer in exactly one of six methods. `ObjectQL.find()` + folded it; `findOne`/`count`/`update`/`delete`/`aggregate` passed the option + bag through with `ast.where === undefined`, which every driver reads as "no + predicate" — so a caller filtering with `{ filter }` silently matched EVERY + row: + + | call | before | after | + | ------------------------------------ | ----------------------- | -------------- | + | `findOne({filter: {status:'done'}})` | first row of the table | a matching row | + | `count({filter})` | whole-table count | matching count | + | `update(data, {filter, multi:true})` | **every row rewritten** | matching rows | + | `delete({filter, multi:true})` | **table emptied** | matching rows | + | `aggregate({filter, …})` | aggregated all rows | matching rows | + + This was reachable, not theoretical: the deprecated + `DataEngine{Query,Update,Delete,Count,Aggregate}OptionsSchema` contracts all + declare `filter`, `ScopedContext`/`ObjectRepository` (the cross-object API + handed to L2 hook bodies) forwards its argument verbatim, and the spec's own + hook documentation taught the broken call + (`users.findOne({ filter: { role: 'admin' } })` — now corrected to `where`). + + Every engine entry point now folds through the spec's own #3795 machinery + (`RPC_QUERY_ALIAS_SLOTS` + `foldQueryAliasSlots`) instead of `find`'s + hand-rolled copy, under the #4181 rule: an alias alone folds, redundant + identical spellings collapse, DIFFERENT values for one slot throw + ("Send exactly one") instead of silently picking a winner, and an explicit + `null` alias is a withdrawal. + + **The sixth pair.** `top` → `limit` — the pair the #3795 scope note excluded + as "the OData layer" — joins `RPC_QUERY_ALIAS_SLOTS`. The protocol normalizer + folded it BACKWARDS (`options.limit = Number(options.top)` — the alias + overwrote the canonical key) while `engine.find` folded it canonical-wins, so + `{top: 1, limit: 3}` answered 1 over HTTP and 3 through a direct engine call. + All three readers (wire normalizer, RPC schema parse, engine) now resolve the + pair identically: `top` alone still limits, a conflicting `{top, limit}` is + refused. + + Behavior change to note: option bags that previously smuggled conflicting + spellings (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are now refused + loudly on every path instead of silently resolving differently per layer. + Pinned per method, write paths included — a regression here is silent and + destructive, and the class went unnoticed precisely because `find` was the + only method anyone thought to check. + +- 58a03d2: fix(objectql,spec,metadata-protocol,service-queue): engine option bags are now a closed contract — unknown keys throw instead of silently doing nothing (#4371 option 2) + + The engine declares `Engine*OptionsSchema` but never parses it at runtime, so + any option key outside the contract — a typo (`orderby`), a retired key + (`cursor`), a wire-protocol leftover (`object`, `count`), a key that only + works on other methods (`tenantId` on `count`) — rode along and was silently + ignored. All six methods now reject non-null unknown keys, naming the legal + set; retired keys (`cursor`/`distinct`) quote their #4286 tombstone; `null` + stays a withdrawal. + + Per-method legal keys = the method's schema keys plus the documented extras: + `searchFields` (now declared on `EngineQueryOptionsSchema` — it was read by + the engine's `$search` expansion and sent by the protocol layer all along), + `onFieldsDropped` on `update` (contract-declared write observability), and + the driver pass-through keys (`transaction`, `tenantId`, `tenantIds`, + `timezone`, `bypassTenantAudit`, `preserveAudit`) on `find`/`findOne`/ + `update`/`delete` — the methods whose bag actually reaches driver options. + `count`/`aggregate` never forward their bag, so pass-through keys there are + rejected rather than accepted-and-ignored. A drift pin holds the sets equal + to the schemas. + + Also closed in the same sweep: + + - A bag-level `object` key used to OVERRIDE the resolved object on the query + AST (`{ object, ...query }` spread order), splitting `ast.object` from the + table actually queried. The AST now keeps the resolved name; a direct call + passing `object` is rejected, and the protocol layer refuses a POST-body + `object` that contradicts the route (400 `QUERY_OBJECT_MISMATCH`) instead + of picking a winner. + - `findData` no longer leaks protocol-layer vocabulary (`object`, `count`, + `joins`, `windowFunctions`, `cursor`, `distinct`, non-aggregate `having`) + onto the engine bag. + - Nested expand ASTs (`expand: { rel: { sort } }`) reject the four wire-only + spellings exactly like the top-level bag (#4371 option 1 did the top level). + - The engine's OData-spelling reads (`$search`/`$searchFields`) are gone — + the protocol normalizes to the bare keys; a direct call passing them now + throws instead of half-working on one method. + - `DbQueueAdapter.purge`/`purgeFailed` passed `{ id }` — a key the engine + never read, so purge deleted NOTHING (each delete threw into a warn-level + catch) and purgeFailed always threw. Both now pass `{ where: { id } }`; + the test fake's `delete` no longer accepts the signature the real engine + rejects. + + Migration for direct engine callers (wire/HTTP callers are unaffected): pass + only the keys your method's `Engine*OptionsSchema` declares (plus the extras + above). Anything else previously did nothing — delete it, or move it to the + layer that owns it. + +- c39d713: fix(objectql): a direct engine call carrying `sort`/`select`/`skip`/`populate` now throws instead of silently dropping the parameter (#4371) + + The engine folds `filter`→`where` and `top`→`limit` itself (#4346); the other + four pairs in `RPC_QUERY_ALIAS_SLOTS` fold at the RPC/protocol layer only, + because their value shapes need lowering (`sort`'s `{field: 'asc'}` record + form, `populate`'s name list) that belongs there. A **direct** `engine.find()` + / `findOne()` never crosses that layer, so one of those keys used to ride the + AST verbatim, drivers read only the canonical name, and the request succeeded + with the parameter discarded — `sort` + `limit` ("the latest N") silently + returning an arbitrary N. Three shipped instances were fixed in #4370, and a + fourth sat in the engine's own autonumber seeding (`select` — now `fields`). + + `find`/`findOne` now reject a non-null wire-only spelling with an error naming + the canonical key and shape, e.g.: + + > `find('task') does not accept 'sort': 'sort' is a wire spelling of +'orderBy', folded by the RPC/protocol layer — a direct engine call bypasses +that fold, so the value would be silently dropped, not applied. Pass +'orderBy' (SortNode[]: [{ field, order: 'asc' | 'desc' }]) instead.` + + Migration for direct engine callers (HTTP/RPC callers are unaffected — the + wire fold is unchanged): `select: [...]` → `fields: [...]`; + `sort: {f: 'asc'}` or `sort: [{field, order}]` → `orderBy: [{field, order}]`; + `skip: n` → `offset: n`; `populate: ['rel']` → `expand: {rel: {object: 'rel'}}`. + An explicit `null` under a wire spelling remains a withdrawal (ignored), and + the where-only methods (`update`/`delete`/`count`/`aggregate`) are unchanged — + their contracts honour no sort/projection/pagination in either spelling + (unknown-key enforcement there is #4371's follow-up scope). + +- 91f4c78: fix(automation,objectql,spec): attribute `runAs:'system'` flow writes to the flow in the audit log (#4366) + + A `runAs:'system'` flow's data writes carried no attribution at all: the run + context resolved to `{ isSystem: true }` with no `userId` and no service + principal, so the audit writer recorded `user_id=null, actor=null` and the + record-history UI rendered every such row as "Unknown user" — business users + read the flow's own status mirror as data corruption. + + The `svc:*` attribution channel (ADR-0014 D2, `ExecutionContext.actor`) already + existed for exactly this class of writer; it was simply never wired end-to-end: + + - **service-automation** — `resolveRunContext` now stamps `flowName` alongside + `runAs`/`flowRunId`, and `resolveRunDataContext` labels a `runAs:'system'` + run's data context `actor: 'svc:flow:'` (fallback + `svc:flow:automation`). Attribution only — no security middleware keys on it. + - **objectql** — `buildSession` propagates `ExecutionContext.actor` onto the + hook session, closing the gap that left the audit writer's + `userId ?? session.actor` fallback unreachable from the engine path. + - **spec** — `AutomationContext.flowName` (engine-stamped, provenance) and the + hook session's optional `actor` field document the contract. + + No behavior change for user-attributed writes: `userId` still wins wherever it + is present. + +- 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363) + + #4363 made a single paged read a partition of its result set. It could not make + a _walk_ one: seven background scans paged with a growing `offset` while writing + to the very rows they were reading, and an offset counts into a set those writes + are changing. Rows slide past the cursor and are never visited. + + That is not a slow page in any of these — it is a wrong answer wearing the shape + of a clean run: + + - **`rebuildApproverIndex`** built its desired state by walking + `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then + **deleted** every index row that state did not explain. A skipped request + meant an approver silently dropped from someone's queue. (The loop beside it + ordered by `created_at` — not unique, so its pages were never a partition + either.) + - **`verifyFileReferences`** decides which files nothing references. A record it + never visits is reported as an unreferenced file. + - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite + each row they read, so their own writes were shifting the set out from under + the cursor. Records were left unconverted and unsearchable by a run that + reported success. + - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and + it opens a migration gate on that evidence. + + All of them now go through `keysetWalk` (`@objectstack/types`): order by a + unique key, and seek past the last one instead of counting from the start. A + row's key does not move when the row is updated, and cannot be shifted when + another is deleted, so the walk is stable under exactly the mutation these + functions perform. It is also O(n) rather than O(n²/page) — measured on + Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek. + + One deliberate non-conversion: the REST **export** stream keeps its offset. It + honors a caller-chosen sort, and a keyset walk would have to re-order the export + by `id` to seek — changing what the user asked for to fix a cost. Its pages are + already a partition since #4363; only the depth cost remains. + + `keysetWalk` merges the cursor with `$and` rather than spreading it into the + caller's filter, so a walk whose own `where` constrains the key column + (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently + overwritten. When a `max` cap is set it reads one row beyond the cap to tell + "the cap stopped us" from "the source ended exactly there" — without that, a + walk that read everything still reports `truncated`, and a caller acting on it + goes looking for rows that were never withheld. + + The storage suites' fake engines now **throw** on an `offset` instead of serving + one, so the conversion is pinned rather than merely passing. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- d13004a: feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + + `kernel.use()` registration order was never a contract — the kernel resolves + init/start order from the plugin dependency graph — but a plugin that needed a + service at init _when its provider is composed_ while also booting _without_ + the provider had no way to declare that. `AppPlugin` was the standing example: + it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing + (a hard dependency would break empty-env / metadata-only / mock-engine + kernels), and so its correctness rode on which array slot each caller put it + in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s + first cut; then #4085, disguised for months as "crashes when the artifact is + missing"). + + The kernel `Plugin` contract gains three additive fields, enforced by both + `ObjectKernel` and `LiteKernel` through one shared implementation + (`plugin-order.ts` — the previously duplicated topological sort is unified + there): + + - **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. + - **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. + - **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + + Plugins that declare nothing get the diagnosis too: a `getService` miss + during Phase 1 now appends which plugin was initializing and — when a + composed plugin declares the service — who provides it and how to declare the + ordering. The `Service '' not found` prefix and the factory-backed + `is async - use await` message are unchanged. + + First adopters: `AppPlugin` declares + `optionalDependencies: ['com.objectstack.engine.objectql']` + + `requiresServices: ['manifest']` (cleared on the empty-env no-op path), so + the #4085 composition — AppPlugin registered before the engine — now boots + correctly in every slot; `ObjectQLPlugin` declares + `providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and + `MetadataPlugin` declares `providesServices: ['metadata']`. + + Everything is additive — plugins that declare nothing keep their exact + ordering semantics; no existing declaration changes meaning. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- af2a095: fix(data): `searchFields` / `groupBy` / `aggregations` naming a field that does not exist are rejected, not silently degraded (#4254) + + #4226 closed `sort` / `select` / `expand`; with the filter axis (#4134 / #4164 / + #4181 / #4121) that made four field-naming read axes that either apply or fail. + The same machine kept leaking on the remaining three, and each failure corrupted + something the closed axes never touched: + + ``` + search=alpha&searchFields=no_such -> 200 MORE rows than the narrowing allowed + groupBy=[no_such] -> 200 [{no_such: null, n: }] N groups collapsed into 1 + sum(no_such) -> 200 0 — indistinguishable from a real zero + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `POST /data/:object/query`, the export route and the runtime dispatcher give + one answer instead of four. + + - **`searchFields` → `400 INVALID_FIELD`.** The `select` failure with the sign + flipped outward: the engine dropped unknown names and, when that emptied the + override, fell back to the FULL searchable set — so a parameter that exists + only to narrow a search widened it, and it changed which ROWS came back, not + just which columns. Its only in-framework caller is `GET /data/:object/export` + — the route whose `search` support just shipped so exports would stop + downloading "the unsearched superset … in a file that looks authoritative"; + a typo'd `searchFields` did exactly that, one parameter over. Three causes, + three messages, because the fixes differ (the split #4226 drew on expand): a + name that is no field is a request typo; a REAL field outside the searchable + set needs the object changed (its message names the declared + `searchableFields` or the auto-default's type rule, whichever applies); and + a `searchableFields` entry that names no field is a STALE DECLARATION — a + bug on the object, called out as such because clients (objectui's list + search) echo the declaration verbatim. The allowed set is resolved by the + same `@objectstack/spec/data` function the engine's search expansion + consumes (`resolveSearchFieldResolution`, moved from objectql), so the gate + cannot drift from what search actually scans. + - **`groupBy` → `400 INVALID_FIELD`.** The in-memory aggregation path projects + an unknown column as `null` for every row, so all rows landed in ONE bucket + whose count is the true row count — structurally perfect, identical to "this + column really holds a single value". A chart draws one bar; nothing says the + grouping never ran. Native SQL aggregation errors on the same input, so which + backend a deployment sits on decided the answer — the "two routes, opposite + answers" split, one axis over. + - **`aggregations` → `400 INVALID_FIELD`.** `sum()` folded a column of + `undefined` to `0` — the exact number an empty quarter produces, in reports + whose whole job is to be believed (`avg`/`min`/`max` answered `null` the same + way). `count` with no `field` (or the `'*'` sentinel) is the one legitimate + field-less form and passes. + - **Unreadable SHAPES on the aggregation axes → `400 INVALID_QUERY`** — the + standard-catalog code that had no emitter since it was written, like + `INVALID_SORT` before #4226. A string `groupBy`, an entry naming no field, a + function or `dateGranularity` outside the spec enums, a missing `alias`: each + slipped past the `Array.isArray` routing guard (rows returned UNGROUPED) or + computed a silent placeholder (`null` results, a column keyed `"undefined"`, + one bucket per raw value under an unknown granularity). + + Tiering is unchanged from #4226: registry + field map present → authoritative; + no registry / no field map / legacy array field map → the NAME gates skip (shape + gates still apply — they need no schema). The engine's own tolerance is + untouched: internal callers reaching `engine.find()` / `engine.aggregate()` + directly are unaffected. `@objectstack/rest` also stops logging + `INVALID_FILTER` / `INVALID_SORT` / `INVALID_QUERY` rejections as + "[REST] Unhandled error" — they are client mistakes the response already + explains, as `INVALID_FIELD` always was. + + Requests that name real fields are unaffected. + +- bf478e1: fix(data): `sort` / `select` / `expand` naming a field that does not exist are rejected, not silently dropped (#4226) + + The list path has four axes on which a caller names a field. `filter` was + closed over #4134 / #4164 / #4181 / #4121 — a filter the server cannot apply is + now a 400, never a 200 over the wrong rows. The other three still leaked, all + answering `200`: + + ``` + sort=no_such_field -> 200 CAEBD byte-identical to "no sort at all" + select=no_such_field -> 200 asked for one column, got all of them + expand=no_such_rel -> 200 no relation, no complaint + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `GET /data/:object/:id`, `POST /data/:object/query`, the export route and the + runtime dispatcher give one answer instead of five. + + - **`sort` → `400 INVALID_SORT`.** The row set is unchanged, so this is not + #4181's "returned everything" — it is worse in one specific way: `sort` + + `top` is how a caller asks for "the latest N", and a dropped sort makes that + an arbitrary N that nothing in the response reveals. This is the list half of + the bug #4181 fixed on the export route's `orderby`. `INVALID_SORT` had sat + in the standard catalog since it was written with no emitter. + - **`select` → `400 INVALID_FIELD`.** `engine.find()` drops unknown columns + (deliberate `SELECT *` tolerance) and then falls back to `*` when that empties + the projection, and the two compose into `?select=` asking for ONE + column and receiving EVERY column — a parameter whose purpose is to return + less, failing by returning more, against both FLS and data minimisation. The + partially-unknown case (`?select=title,no_such`) is refused on the same terms: + half a projection is not the one that was asked for, and the tolerant reading + would have to explain why `?status=` is a 400 and `?select=` is + not, on one endpoint, about one field map. + - **`expand` → `400 INVALID_FIELD`.** The lightest of the three — same rows, + same columns, the relation simply is not there — but the response cannot be + told apart from "every foreign key is null", and the client renders raw ids + where names belong. A name that is no field at all and a name that is a field + holding no reference (`?expand=title`) get different messages, since the fixes + differ. + + **Sorts that were silently never applied now are.** Two wire spellings reached + the normalizer and fell through it untouched, and every driver then declined + them (`SqlDriver` guards its ORDER BY with `Array.isArray(orderBy)`): the + client SDK's own declared `orderBy: string[]`, and the `{field: direction}` map + that `GET /data/:object/export`, `GET /data/import/jobs` and objectui's calendar + all emit. Both are now folded to `SortNode[]` — so the import-job history, which + has asked for `created_at desc` since it was written and served insertion order, + sorts. A sort shape that still cannot be read (a number, an entry naming no + field, a direction that is neither `asc` nor `desc`) is `400 INVALID_SORT` + rather than a silent no-op. + + **`$expand` of a `tree` field works.** `REFERENCE_VALUE_TYPES` lists `tree` + among the types whose value "points at another record … the related record + object in expanded form", and objectui requests it, but + `engine.expandRelatedRecords` tested membership with a hand-copied `!==` chain + that omitted it — so a hierarchy field came back as a raw parent id. The loop + now reads the shared spec set, which is also what the new expand gate validates + against, so the gate cannot admit a field the engine then skips. + + **What changes for callers:** requests naming a non-existent field in `sort`, + `select` or `expand` now fail loudly instead of receiving an unsorted, widened + or unexpanded response. Every axis naming real fields is unaffected. The + engine's own tolerance is untouched — it guards internal callers (hooks, flows, + expand sub-reads, registry-less hosts) that never pass through this ingress, + the same tiering the object-existence and unknown-field gates already use. + +- 5d21a48: feat(spec,metadata-protocol,metadata,objectql,service-automation): stored metadata replays the full conversion chain at rehydration (#3903) + + Every mechanism the platform has for evolving the metadata contract — schema + transforms, the ADR-0087 D2 conversion layer, the D3 migration chain, the + protocol-17 tombstones — operated on **authored source** only. Metadata **at + rest** (`sys_metadata` rows written by Studio or the runtime authoring APIs) + was rehydrated unparsed and unconverted, so the authored and stored contracts + silently diverged: a pre-17 row carrying `conditionalRequired` or `execute` + read as whatever each ad-hoc consumer happened to do with it. + + **New spec primitive — `applyConversionsToStoredItem(type, item, options?)`** + (exported from the package root). Wraps one stored item of a given metadata + type and replays the **full** conversion chain over it — `retiredFromLoadPath` + entries included, because retirement is an _authoring-surface_ event: the + window exists to teach a live author, and a row at rest has no author to + teach. Idempotent, never throws, never validates. + + Wired at every stored-row rehydration seam: + + - `metadata-protocol`: `loadMetaFromDb`, `getMetaItems` (active + draft + preview), `getMetaItem` (active + draft), `getMetaItemLayered`, and + `duplicatePackage` (a copy re-saves through the schema gate, so legacy + sources now duplicate successfully — and the copy is canonical). + - `metadata`: the DatabaseLoader's live-row reads (`load` / `loadMany`). + History reads stay verbatim — history records what was written. + - `objectql`: the authored-action / authored-hook direct table reads, so + runtime-authored actions stored with the removed `execute` alias dispatch + via `target` again. + - `service-automation`: `AutomationEngine.registerFlow` now passes + `includeRetired` — stored flows keep canonicalizing after their conversions + graduate out of the load window. (The generic metadata seams deliberately + skip `type: 'flow'`: flow conversions carry the open-namespace conflict + guard, which needs this engine's live executor registry.) + + **Boot hydration diagnoses instead of shrugging.** `loadMetaFromDb` now + returns `{ loaded, errors, invalid }`: each row is validated against its + type's spec schema _after_ conversion, and a genuine contract violation is + counted and warned with a stable `[metadata_spec_invalid]` marker — but still + registered, deliberately: refusing at boot would unhook live tables and make + the row unlistable and unfixable in Studio. The write path (`saveMetaItem` → 422) and the read-side `_diagnostics` envelope remain the enforcing gates; the + `SchemaRegistry.registerItem` validation hook is now documented as exactly + that diagnostic. + + **Retired accommodation.** With the chain running on every stored read path, + the rule-validator's `requiredWhen ?? conditionalRequired` fallback — kept in + #3883 with a retirement promise that had no mechanism — is deleted. If you + call `evaluateValidationRules` directly with raw legacy field definitions, + convert them first (`applyConversionsToStoredItem('object', def)`) or author + `requiredWhen`; the platform's own read paths already hand you canonical + shapes. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [a4a9944] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [4475c59] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [f5fe061] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [8d5bb5a] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [a62bd9e] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [3245174] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/metadata-protocol@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 5c178aeaa5..66bcf3c4cd 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 91677f1ca8..7eb024bd44 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,173 @@ # @objectstack/observability +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index c518905e29..e6a8ddfddc 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 d3db49087c..0dc9731c9b 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,662 @@ # @objectstack/platform-objects +## 17.0.0-rc.1 + +### Minor Changes + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +- 68dea0b: feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243) + + The deployment-level data-migration flag ledger (`sys_migration`, #3617) was + registered by `@objectstack/service-storage` as its first consumer. That was + deliberate while the file migration was the only consumer, but the ledger now + gates storage-independent behaviour too — `os migrate value-shapes` (#4235) + and the fresh-datastore attestation (#4215) — and a non-file migration had to + boot the whole storage plugin just so the kernel carried the table. Any kernel + assembled without storage silently had no ledger at all, which read exactly + like "migration not run" (both answer false) while actually meaning "ledger + not installed". + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the ledger exists with the + platform, independent of which optional services are composed. The + fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger + bookkeeping, and its old home justified itself as "the service that registers + `sys_migration`". Definition ownership is unchanged (`sys_migration` stays in + `@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the + flag helpers and readers are untouched. + + Consequences: + + - `@objectstack/service-storage` no longer contributes `sys_migration` to the + manifest and no longer performs the fresh-datastore attestation. An embedder + composing `StorageServicePlugin` on a hand-built kernel that relied on it + for the ledger must compose `PlatformObjectsPlugin` (the plugin every + supported assembly path already includes). + - The CLI's `buildDataMigrationPlugins()` no longer boots storage for every + gated migration — it registers `PlatformObjectsPlugin` always, and settings + - storage only for `os migrate files-to-references` (`{ storage: true }`), + the one migration that actually reconciles against the storage adapter. + +- 64f8cbe: feat(platform-objects,service-settings,verify): `sys_secret` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the settings service (#4270) + + The environment's encrypted-secret store (`sys_secret`, ADR-0066 D2/④) was + registered by `@objectstack/service-settings`, but it has three producer + classes and only one of them is settings: the settings service's encrypted + specifiers, the ObjectQL engine's own `secret`-field encryption + (`encryptSecretFields`/`resolveSecret` — the generic write path of ANY + business object carrying a `Field.secret()`), and the datasource credential + binder. Unlike the `sys_migration` precedent (#4243), the failure posture is + fail-CLOSED: on a kernel composed without settings, every insert/update of an + object with a secret field threw — with an error message that told the + operator to "Ensure the platform-objects (sys_secret) are registered", naming + a package that did not register it. + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the store exists with the + platform, independent of which optional services are composed, and the + engine's fail-closed error message is true. Definition ownership is unchanged + (`sys_secret` stays in `@objectstack/platform-objects` and in + `PLATFORM_OBJECTS_BY_PACKAGE`); the settings service remains a producer and + consumer through its `sys_secret`-backed secret store. + + Consequences: + + - `@objectstack/service-settings` no longer contributes `sys_secret` to the + manifest (`settingsObjects` is now `[SysSetting, SysSettingAudit]`). An + embedder composing `SettingsServicePlugin` on a hand-built kernel that + relied on it for the `sys_secret` table must compose + `PlatformObjectsPlugin` (the plugin every supported assembly path already + includes). The move REPLACES the registration — nothing registers the + object twice. + - `@objectstack/verify`'s boot harness now composes `PlatformObjectsPlugin`, + mirroring `os serve`'s auto-inject — which also means harness kernels now + carry the `sys_migration` ledger + fresh-datastore attestation (#4243) the + served assembly always had. + +### Patch Changes + +- 09e4547: feat(spec)!: reject unknown keys across the app shell and navigation tree (#4001 app step, PR B) + + Closes the last high-traffic authorable surface in the unknown-key strictness + ratchet (flow + permission #4071, RLS / sharing / position #4099, approval + #4119, App dead-key tombstones #4142). The app shell is the densest + hand-authored surface on the platform — a navigation tree is where an author + or AI is most likely to write a key from memory — so a silent strip here was + the most probable instance of the #3405 trap. + + - **`AppSchema`** and its sub-schemas (`AppBrandingSchema`, + `NavigationAreaSchema`, `AppContextSelectorSchema` + its `optionsSource` / + `filter` blocks, `NavigationContributionSchema`) are `.strict()`. + - **`NavigationItemSchema` becomes a DISCRIMINATED union on `type`.** This is + what makes strict readable: a plain union of strict members answers one + unknown key with an `invalid_union` aggregate naming all nine branches, + while discriminating on `type` first yields a single `unrecognized_keys` + issue against the branch the author actually wrote — at an exact path + through nested `children` — and a mistyped `type` gets its own "Invalid + discriminator value". Each variant carries its own suggestion pool, so a + `url` item is never told about `dashboardName`. + - **Still OPEN by design:** `PageNavItem.params`, `ComponentNavItem.params` + and `ActionNavItem.actionDef.params` — per-target payloads owned by the + page / component / action, not by the nav item. + + **A real defect the gate caught, in the platform's own app:** `ACCOUNT_APP` + declared `defaultOpen` on three navigation groups. That was never a schema + key — `expanded` is — so all three shipped COLLAPSED while their author + believed they opened by default. Fixed at the producer (contract-first) and + `defaultOpen` / `open` / `collapsed` / `isOpen` now alias to `expanded`. + + **Migration.** Any key now rejected was previously stripped and had no + runtime effect. The error carries the fix; mappings include + `menu`/`sidebar`/`tabs`/`items` → `navigation`, `title` → `label`, + `permissions` → `requiredPermissions`, `sort`/`position` → `order`, + `defaultOpen` → `expanded`, `args` → `params` (actionDef), `primary` → + `primaryColor`, `url` → `endpoint` (options source), plus wrong-layer + pointers: `pages`/`views`/`flows` are not App fields, and a payload named on + the wrong variant points at the `type` that owns it. + + The `visibleWhen` → `visible` alias is the load-bearing one: ADR-0089 made + `visibleWhen` canonical on view/page schemas, so an author who learned it + there would silently lose a nav entry's visibility gate — a capability gate + failing open, the worst shape of the silent-strip bug. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- e59786e: fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) + + A recursive Zod schema needs an explicit annotation to break its circular + inference, and five of them took the cheapest one available: + + ```ts + export const NavigationItemSchema: z.ZodType = z.lazy(() => …); + export type NavigationItem = z.infer; // → any + ``` + + It compiles, it validates correctly at runtime, and it silently throws the type + away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all + `any` on the published surface, plus `FieldNodeSchema` — which had no exported + type alias yet, so `z.infer` was `any` and + `QueryAST['fields']` with it. + + That is worse than a missing export. #4115 tells every consumer that a local + declaration under a spec export's name must be replaced by a binding to the + spec — and for these, obeying it **replaced a precise type with `any`**. + objectui's `NavigationItem` is a 118-line documented interface (`recordId` + template variables, `requiresObject` / `requiresService` capability gates, + `filters` precedence); every key of it exists in the spec's version, so by every + available signal it read as a redundant fork safe to delete. Deleting it swapped + a fully-typed interface for `any`, with no compile error anywhere to say so. + + It is hard to catch by inspection because `any` is mutually assignable with + everything, so the natural "are these the same type?" check answers _yes_ in both + directions and recommends precisely the wrong action. Same failure family as + #4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything + reads as agreement. + + **Now annotated with the real type**, using the pattern `QueryAST` already + follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive + knot in the type, so the keys stay derived from the schema instead of being + hand-maintained beside it: + + ```ts + const BaseXSchema = z.object({ …every non-recursive key }); + export type X = z.infer & { children?: X[] }; + export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + children: z.array(XSchema).optional(), + })); + ``` + + `z.infer` now resolves to the type it should always have been: `NavigationItem` + is the nine-branch discriminated union, `FormField` the 30-key form-field + contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into + `visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` + the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime + validation is unchanged: every schema parses exactly what it parsed before. + + **What the types immediately caught**, none of it visible while they were `any`: + + - `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has + never declared. It worked only because objectui's `NavigationRenderer` still + falls back to that legacy alias. Fixed at the producer per Prime Directive + #12: the canonical key is `expanded`. + - The MongoDB driver built its projection with `projection[field] = 1` over + `query.fields`, so a relationship `FieldNode` would have keyed the projection + on `"[object Object]"`. It now reads the node's field name. + - `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated + with the PARSED `App` / `NavigationContribution` types but omitted + `.default()`ed keys (`expanded`, `target`), as did the form fields + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the + default it was relying on, matching what the surrounding literals already do + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. + + **Gated, not just fixed** (`check:exported-any`, wired into the required + `TypeScript Type Check` job). `api-surface.json` records that an export _exists_ + and never what it _resolves to_, which is how these survived a whole major with + every gate green. The new scan reads the built `.d.ts` a consumer's import + actually resolves to and fails on any exported type that resolves to `any` — or + any exported schema whose output is `any`, the root cause, and the only reason + `FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and + currently empty. It self-tests against the real zod first, so if the internals it + reads are ever renamed the gate fails loudly instead of quietly passing + everything forever. + +- 20bc1ec: fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786) + + Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout + that names keys of a Zod schema it never imports: two descriptions of one key + set, a comment asking the next author to keep them in step, and nothing that + fails when they don't. #3786 asked for a sweep of that shape across the repo. + **Four of the seventeen forms had already drifted, every one of them silently.** + + The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not + `.strict()`, so a key the schema does not declare parses clean and is stripped + on the way to storage — the same ADR-0104 failure class the `field.zod.ts` + prune tombstone already describes in prose. An admin toggled a switch in + Studio, got no error, and the value never landed. + + **What was broken, from an author's seat:** + + - **Object → Capabilities.** The block bound to `capabilities`; the + `ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable, + API enabled, Files, Feeds, Activities, Clone) saved nothing. + - **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has + never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`, + `Placeholder`, `Validation`/`Error message` and `Starting number` were + controls with no storage behind them at all; the rest named keys the schema + had **renamed** and the form never followed: + `referenceFilter` → `lookupFilters`, `cascadeDelete` → `deleteBehavior` + (a three-way enum, not a boolean), `formula` → `expression`, + `displayFormat` → `autonumberFormat`, and the flat `summaryType` / + `summaryField` pair → the single `summaryOperations` object, which also + restores the `object` key the flat pair had no slot for. Roll-ups authored in + that grid saved nothing. + - **Report → Advanced.** `aria` and `performance` were pruned from + `ReportSchema` by #3496; the form kept rendering both. + - **Hook / Action → Body.** `memoryMb` was unauthorable — named in + `hook.form.ts`'s own doc comment, absent from the list beneath it. + - **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's + default sort order could not be set in Studio at all. + + **No authored metadata changes and nothing you can write is removed.** These + were UI controls that never persisted; every corrected key is one `FieldSchema` + / `ObjectSchema` already accepted. Metadata authored in YAML/TS was always + validated against the real schema and is unaffected. If you had been filling + those Studio controls expecting them to stick, they now either work (the + renamed five) or are gone rather than lying to you. + + The metadata-form translation bundles are derived from the registry, so all + four locales are regenerated. Worth naming what they contained: translated + labels, in four languages, for switches that saved nothing — the drift had + propagated into a generated artifact and been dutifully translated there. + + **The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every + registered form and reconciles it against `getMetadataTypeSchema()`. The two + directions are deliberately asymmetric: **form-only** (a control whose value is + discarded) is always a defect and cannot be excused, because no design wants + one; **zod-only** is ledgerable with a reason, for a deprecated key held back + from new authoring or a curated quick-add subset that defers to a fuller + editor. Ledger entries are checked for non-vacuity and for still resolving on + both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding + a stripped key, dropping a covered key, and offering a ledgered omission each + turn the gate red. + + **New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the + set of metadata types whose labels `translateMetadataDocument` localizes, + derived from its dispatch table rather than restated. `@objectstack/rest` had + been carrying a hand-copied literal set under a "keep in sync with the type + dispatch" comment; it now reads this instead. Registering a translator in spec + reaches the REST boundary with nothing else to remember — the second list is + deleted rather than checked, which is the better half of derive-or-gate. + + Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored + `ToolCategorySchema` in `ai/tool.zod` and told the next author to update both + sides — but #3896 deleted `ToolCategorySchema` along with the inert + `tool.category` key it typed. The instruction had been pointing at a source + that no longer exists. The enum is canonical now and says so. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- d6938bf: fix(spec): the remaining six recursive schemas name both type parameters, and the authoring artifacts stop spelling out defaults (#4195) + + #4221 fixed `NavigationItemSchema` — the worst instance, and the one with a + reproducible "`defineApp` compiles `navigation: [42, 'nonsense']`" demo. This + finishes the sweep: **six more schemas** had the same shape, and the authoring + artifacts that #4171 had to work around can now be typed honestly. + + `z.ZodType` takes `` and `Input` defaults to `unknown`, so naming + only the first parameter leaves `z.input` of anything embedding that schema at + `unknown`. Measured with a type probe: + + | | was | now | + | -------------------------------------- | ----------- | ------------------------- | + | `QueryInput['joins']` | `unknown[]` | `JoinNodeInput[]` | + | `QueryInput['fields']` | `unknown[]` | `FieldNode[]` | + | `z.input` | `unknown` | `FormFieldInput` | + | `z.input` | `unknown` | `QueryInput` | + | `z.input` | `unknown` | `StateNodeConfig` | + | `z.input` | `unknown` | `BaseValidationRuleShape` | + + New exported types: `FormFieldInput`, `JoinNodeInput`, `NavigationContributionInput`. + `FilterCondition`, `NormalizedFilter` and `FieldNode` carry no `.default()` or + `.transform()`, so their input is their output and the second parameter is the + first. + + **The `z.ZodType` single-parameter form is now absent from the codebase.** + + ## 26 hand-written defaults deleted + + This is the half #4221 left on the table. #4171 had to spell out + `expanded: false` (×16) and `target: '_self'` (×10) across `setup.app.ts`, + `studio.app.ts` and `setup-nav.contributions.ts`, because those artifacts are + annotated with the PARSED type where a `.default()`ed key is required — and + retyping them to the input surface would have traded eight loud errors for no + checking at all. + + With `NavigationItemInput` landed (#4221) and `NavigationContributionInput` + added here, they are annotated `AppInput` / `NavigationContributionInput`, the + defaults are defaults again, and the literals are checked for the first time. + Net across those four files: 21 lines added, 54 removed. + + Verified live, not nominal: a literal omitting `expanded`/`target` compiles, and + one writing `defaultOpen` — the non-spec key #4171 found in `account.app.ts` — + is a compile error whose suggestion list names `expanded`. + + ## Two typed with a documented caveat + + `StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for + both parameters: exact on the input side, loose on the output side. + `StateNodeConfig` marks `type` optional though `.default('atomic')` makes it + always present; `BaseValidationRuleShape` carries a `[key: string]: unknown` + index signature. Both were already that loose — input went from `unknown` (types + nothing) to a real type, output is untouched. Making them exact means deriving + those types from their schemas instead of maintaining them beside one, which is + separate work; the caveat is written at each declaration rather than left for a + reader to find. + + ## Why there is still no CI gate for this + + Worth recording, since #4195 proposed one: extend `check:exported-any` to fail on + "output precise but input `unknown`". Measured after this change — exactly two + schemas match, `TranslationItemSchema` and `InlineActionSchema`, and **both are + correct**: they are `z.preprocess(...)`, where an `unknown` input is zod's + semantics rather than a missing annotation. Separating those from a genuinely + missing parameter needs heuristics on emitted type names, and per the rule in + that script's own header — zero false positives, so red keeps meaning broken — a + gate that cannot be made reliable is worse than none. #4221's + `app.nav-type-assertions.ts` is the better pattern where it applies: pin the + contract at compile level rather than infer intent from shape. + +- d92c72d: fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + + The three selectors from #4321 all key off the erasure and the lookup being in + ONE expression. Split them and every selector misses: + + ```ts + let ql: any; + try { + ql = ctx.getService("objectql"); + } catch { + /* optional */ + } + ``` + + Selector 1 needs the call inside the declarator (this declarator has no init), + selector 2 needs `as`, selector 3 needs a type argument. The contract is erased + exactly as in `const ql: any = ctx.getService(…)`. + + **Why this could not wait for the batches.** The baseline's monotonicity check + means a file that leaves the grandfather list can never be re-added. So every + batch converted more of this shape from "grandfathered" into "lint covers this + file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` + into that state. A ratchet that reports a cleaner number the more you sweep is + the #4342 failure wearing different clothes, and the fix only gets more + expensive per batch shipped. + + **It is a rule, not a fourth selector, and that is the whole finding.** esquery + can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell + which declaration the assigned identifier resolves to — so it would equally + flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in + runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the + identifier needs SCOPE analysis. That is cheap and needs no type information, so + this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it + is a rule, and the earlier "just one more selector" estimate was wrong. + + Verified against exactly that: the rule flags all 16 real sites and none of the + 8 correctly-typed lookalikes. + + **Scale.** The baseline goes 140 → **169 sites** with the file count unchanged + at 37: 29 sites were already inside grandfathered files and simply invisible. + 16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 + in files never listed, 1 the regex sweep had missed) and are typed here — + `runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, + `plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, + `runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was + added; the key set still only shrinks. + + Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, + `II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they + do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, + `EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of + those record something worth naming: `IHttpServer` has no `getRawApp()` (the + contract is framework-agnostic and the raw app is Hono's own handle), and + ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader + at all — the engine attaches them via `(this as any)` and publishes nothing, + while `getHookMetricsRecorder()` exists for exactly that question about the + metrics recorder. Declared rather than laundered through `any`, and filed. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 7594a17952..79d8e44d3f 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 d0a85df82b..739c90d19f 100644 --- a/packages/plugins/driver-memory/CHANGELOG.md +++ b/packages/plugins/driver-memory/CHANGELOG.md @@ -1,5 +1,589 @@ # @objectstack/driver-memory +## 17.0.0-rc.1 + +### Major Changes + +- 7309c81: fix(driver-memory,spec): persistence is opt-in again — `new InMemoryDriver()` is pure in-memory (#4065) + + `InMemoryDriverConfig.persistence` defaulted to `'auto'`, and in Node.js `'auto'` + means **file**. So a bare `new InMemoryDriver()` — the shape every caller in this + repo used — silently wrote `.objectstack/data/memory-driver.json` into the process + CWD and reloaded it on the next boot. The default is now `false`. + + **This restores the accepted design rather than replacing it.** #815, the issue + that introduced the persistence capability, specified it as opt-in in requirement + \#1 — "默认情况下不启用持久化(纯内存,行为不变)" — and listed + `new InMemoryDriver()` under "纯内存" in its own config examples. The `'auto'` + default was a drift from that spec. + + What let the drift survive is worth naming, because it is not "there was no + test". `MemoryConfigSchema` _did_ pin the default, and asserted `'auto'`; the + driver honoured `'auto'`; so spec and implementation agreed, and the pair looked + verified. What nothing checked was whether the value they agreed on was the one + #815 accepted. The driver's own `persistence.test.ts` could not have caught it + either — every case there passes `persistence` explicitly, so the omitted-value + path was untested on the implementation side. Both sides are now covered: three + behavioural tests in `persistence.test.ts` (no CWD write, no cross-instance row + carry-over, opt-in still persists) and the flipped schema assertion. + + **The symptom this fixes.** `packages/runtime/src/datasource-autoconnect.test.ts` + seeds two rows with fixed ids and asserts the exact set. Run 1 passed and wrote + the rows to disk; run 2 loaded them back, appended two more, and failed with four + rows; run N had 2N. CI never saw it — every job is a fresh clone, so every CI run + is run 1 — but `pnpm test` twice in one working tree could only ever go green + once. The persisted file's `created_at` values, one pair per run, were the proof. + + (#4083 fixed that particular suite from the factory side, and its regression + test is kept as-is. The blast radius was wider than one suite, though: **every** + bare `new InMemoryDriver()` inherited the default, so any code path constructing + one directly wrote to its working directory. Unit tests should not have write + side effects on the CWD at all.) + + **Migrating.** Callers that want durability now ask for it: + + ```ts + new InMemoryDriver(); // pure in-memory (new default) + new InMemoryDriver({ persistence: "file" }); // Node.js, durable across restarts + new InMemoryDriver({ persistence: "local" }); // browser, durable across reloads + new InMemoryDriver({ persistence: "auto" }); // previous default behaviour + ``` + + The `'auto'` / `'file'` / `'local'` / custom-adapter paths are unchanged; only + the value used when `persistence` is omitted moved. + + **Relationship to #4083.** That issue fixed the same hazard one consumer at a + time, and landed first: `createDefaultDatasourceDriverFactory` now passes + `persistence: false` for a declared `{ driver: 'memory' }` datasource and scopes + an opted-in destination _per datasource_, and the dev sqlite step-down's + last-resort rung passes `false` too. Both are kept exactly as #4083 wrote them. + This change closes the half they deliberately left open — a directly-constructed + `new InMemoryDriver()` — which is the path that still wrote into the working + directory of whatever process happened to build one. + + The two are complementary, not redundant. #4083's per-datasource scoping is + still the only thing that expands `'auto'`/`'file'`/`'local'` into a destination + carrying the datasource name, so two pools that DO opt in never alias one file; + its explicit `false` becomes belt-and-braces, which is the right posture for a + path that must never persist. + + `DevPlugin`'s driver is now explicitly `persistence: false`, matching the cache, + queue, job, i18n, storage and search stubs it ships beside — it was the one piece + of that stack that quietly outlived the process. + + **One claim trimmed, no behaviour attached.** The class docstring called this a + "production-ready implementation of the ObjectStack Driver Protocol". It stores + no constraints at all — `create()` is a `table.push()` and `syncSchema()` only + allocates an array — so there is no primary key, uniqueness, `NOT NULL`, foreign + key or column typing, and `bulkCreate` lands duplicate ids where a SQL driver + raises a violation (the second finding in #4065). The docstring now says so, and + points test authors at in-memory SQLite. Per Prime Directive #10 the fix for + `declared ≠ enforced` is to implement it, trim the claim, or file it; with this + driver moving to maintenance-only the claim is what goes. + +### Minor Changes + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +- 6f98c2d: fix(driver-sql,driver-memory): an uncompilable filter now throws instead of matching everything (#3948) + + A filter the driver could not compile was **skipped**, not rejected. No predicate + was emitted and the query returned every row — the caller asked to filter and + silently received the unfiltered set. + + The reachable shape is a bare comparison triple. `['close_date','before','2024-01-01']` + arrives at a driver only when `isFilterAST()` refused it — its operator is outside + `VALID_AST_OPERATORS`, so `parseFilterAST()` never converted it and the raw array + was assigned to `where`. `driver-sql`'s loop then saw three _strings_, matched + neither `and` nor `or`, and `continue`d past all three. `driver-memory` was worse: + it cast every string to a logic keyword, opening three empty groups and returning + `{}` — a filter matching every record. + + This is reachable from ordinary authoring, not just malformed input: `before` and + `after` are canonical `VIEW_FILTER_OPERATORS` members that `VALID_AST_OPERATORS` + does not accept. Eight of the nineteen canonical view operators are in that + position, including `equals`; the others were masked only because ObjectUI's + adapter alias table happened to cover them. + + **Behaviour change.** Both drivers now throw on a filter element that is neither a + logical keyword (`and`/`or`) nor a condition array, and `driver-memory` throws on + an operator it cannot express rather than dropping the condition. The nested and + `$`-object paths already threw on the same input, so this makes the three paths + agree. A caller that was relying on the old silence was receiving wrong results; + the error names the operator and the offending filter. + + **`driver-memory` also gains seven operators it silently ignored:** `not_in`, + `is_null`, `is_not_null`, `isnull`, `isnotnull`, `is_empty`, `is_not_empty` — all + members of `VALID_AST_OPERATORS`, all previously falling through to + `default: return null`. `is_null` narrowed nothing instead of matching null rows. + Alias sets and semantics mirror `driver-sql`'s `whereNull`/`whereNotNull` arms so + the two backends accept one vocabulary. + + Migration: none for well-formed filters. If a query now throws, the filter was + never being applied — fix the operator (the message names it), or lower it to an + AST spelling. `before` → `<`, `after` → `>`, `'not in'` → `nin`. + +### Patch Changes + +- b3a2318: fix(driver-memory,driver-mongodb): a bare-day upper bound covers the whole day (#4042) + + The non-SQL half of #3777's calendar-day rule. Both drivers compiled a bare + `YYYY-MM-DD` `$lte` (and a `between` max) as-is, so on timestamp values the + window cut off at the final day's midnight — the dashboard date-range filter's + default configuration (`created_at`, 7 of 13 presets ending "today") lost the + current day, exactly as it did on SQL before #3777 was fixed. + + Both drivers now compile a bare-day upper bound half-open, sharing + `nextUtcCalendarDay` from `@objectstack/core`: + + - `driver-memory`: the Mongo-style and array `where` spellings in the mingo + lowering (`$lte`/`<=` → `$lt` next day; `$between`/`between` max the same), + the analytics cube-filter `lte`, and the analytics `dateRange` window — which + now also matches BOTH stored forms of a timestamp (ISO strings and `Date` + objects) instead of only `Date`s, since mingo compares cross-type as + never-equal. + - `driver-mongodb`: the `translateFilter` lowering, all three spellings + (`$lte`, `$between`, array `<=`/`lte`). + + Unchanged on purpose, matching the #3777 semantics table: full-ISO/`Date` + comparands keep instant semantics, and `$gte`/`$gt`/`$lt` keep their midnight + anchoring. Known remaining gap (tracked separately): values stored as BSON + `Date` (mongodb) or JS `Date` (memory `find()`) never match _string_ comparands + of any operator — a storage-form problem, not a bound-semantics one. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 9e8f04d: fix(driver-memory,driver-mongodb): `Field.datetime` has one storage form per driver (#4047) + + The non-SQL counterpart of ADR-0053 D-B (#3912). Both drivers let the writer + decide a datetime value's runtime type, and both compare across types by type + bracket rather than by value — so a string comparand never matched a `Date` + value, in either direction, for **every** operator including `$gte`. + + A datetime column genuinely held both forms: the drivers' own + `created_at`/`updated_at` defaults bind a `Date` (mongo) or an ISO string + (memory), while REST/JSON writes, relative-date tokens and `initialData` + fixtures supply the other. A dashboard date window therefore answered with + whichever half happened to match the comparand's type — on MongoDB, where + `created_at` is a BSON `Date` and dashboard bounds are strings, that meant + **no rows at all**, which is worse than the final-day loss #3777 fixed. + + Each driver now has one canonical form, applied on write and to every filter + comparand: + + | Driver | `datetime` | `date` | + | ---------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------- | + | `driver-mongodb` | BSON `Date` — the dialect's native instant, its `timestamptz` | `YYYY-MM-DD` text | + | `driver-memory` | canonical UTC ISO text (sorts chronologically under the string comparison mingo performs; survives JSON persistence) | `YYYY-MM-DD` text | + + Both learn their temporal fields from `syncSchema`, so an object that was never + declared is left exactly as written — the drivers do not guess types from + values. `driver-memory` additionally converges rows already in the table when + the schema arrives, which catches `initialData` fixtures and anything a + persistence adapter restored (the in-memory analogue of + `backfillCanonicalDatetimes`, and idempotent like it). + + `Field.date` deliberately stays timezone-naive text on both — converting it to + an instant would invent a midnight and re-couple it to a zone. The + calendar-day bound semantics from #3777/#4042 are unchanged and now compose + with the converged storage: the whole-day rewrite runs on the calendar string + first, and only the resulting bound is converted to the storage form. + +- 4384921: fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311) + + Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever + read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors, + and most of what looked like sloppy test fixtures was the types being wrong. + + **`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a + long time without being on the schema: `SqlDriver.auditMissingTenant` reads it + to suppress the "tenant-scoped write without `tenantId`" warning, the driver's + own warning text tells callers to set it, `ObjectQLEngine` sets it for + system-context calls, and `service-settings` / `service-datasource` pass it on + every global-scope write. Because the schema never had it, the driver read it + through `(options as any)` and no caller was type-checked. The declaration + states the limit as well: it silences a diagnostic and MUST NOT change which + rows a write touches — suppressing an audit warning is not a permission. + + The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`, + all long since declared. Those reads now go through `DriverOptions`, so the next + undeclared option fails the build instead of hiding behind an existing cast. + + **`SqlDriver.findOne(object, id)` is removed.** An undeclared + `typeof query === 'string' | 'number'` branch accepted a bare id. It was on no + contract, nothing outside that package's own tests used it, and the other two + drivers answered the identical call differently — `MemoryDriver` spreads the + string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined` + and returns an arbitrary row. It also bypassed the shared `findRows()` path, so + it skipped field selection, temporal coercion, unknown-column recovery and the + `singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is: + + ```ts + -(await driver.findOne("task", "t1")); + +(await driver.findOne("task", { object: "task", where: { id: "t1" } })); + ``` + + **`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is + fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the + tenant column and to set or clear the sticky explicit-opt-out — but the + parameter type listed only `{ name, fields }`, so a caller that spelled the key + correctly was rejected while the driver read it anyway. + `registerExternalObject` already had it. + + **`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is + `.default('UTC')`, so the parsed type requires it and an authored literal does + not have it — the same two-tier split `QueryInput`/`QueryAST` already names on + the query side. `InMemoryDriver.create`/`bulkCreate` also declare their + `IDataDriver` return types; without them TS inferred the literal the method + builds and every other column of the created row disappeared from the caller's + view. + + One silent runtime bug fell out of the same pass: a driver test asked for + `orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and + the sort never reached SQL. The tuple spelling appears nowhere else. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- 6038de7: feat(spec,drivers): the temporal conformance matrix gains its `Field.time` axis — and `time` finally gets a storage form off SQL (ADR-0053 D-A3.2) + + `@objectstack/spec/data` gains `TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES`, + the wall-clock half of the shared matrix. A time gets its own table rather than + a third `kind` on the existing one because it shares no comparand vocabulary + with the other two: no relative token resolves to a wall clock, and the + bare-day whole-day rule (#3777) must **not** reach it — which the table now + asserts rather than assumes, since "the rule leaked into the wrong field type" + is exactly what a conformance matrix is for. The fixture is a business day + carrying the boundaries #3994 measured: both window edges, the pair straddling + the millisecond-suffix width change, midnight and `23:59:59.999`. + + **The axis found a real gap on its first run.** ADR-0053 D-C gave `Field.time` + a canonical form on every SQL dialect, but `driver-memory` and + `driver-mongodb` were never extended — both declared + `TemporalFieldKind = 'datetime' | 'date'`, so a `time` column was never + classified and never coerced. It therefore held whatever each writer produced, + and both stores compare across types by bracket: a text bound matched no + `Date`-written row, in either direction, for every operator. Measured on + `driver-memory`, **8 of the 9 shared cases** returned only the text-written + half — a business-hours window answering `[d_mid, f_close]` instead of + `[c_open, d_mid, e_mid_ms, f_close]`. This is #4047's failure one field type + over, and it survived #4047 because that work extended `datetime` and `date` + without revisiting `time`. On mongo it was also a documentation failure: that + module's canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994, + and nothing implemented it. + + Both drivers now carry `storageTimeValue`, mirroring the SQL + `canonicalTimeOfDay`: `HH:MM:SS`, `.fff` only when the milliseconds are + non-zero, a `Date` / epoch / full-timestamp folding to its **UTC** time-of-day + (never the host's), and totality — an out-of-range wall clock like `'25:00'` + passes through rather than being silently rewritten. Text on both, mongo + included: a wall clock is not an instant, so a BSON `Date` would invent a + calendar day and a zone the author never wrote. + + If you have existing `time` data on either driver, values written as `Date` + objects converge to canonical text on their next write; reads of un-migrated + documents are unchanged. Filters were already unable to reach the mixed half, + so no query that worked before stops working. + +- 0166bd5: fix(spec,drivers): the view filter vocabulary and the AST vocabulary now agree (#3948) + + `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a + `ViewFilterRule`. `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates + `isFilterAST()`, which decides whether a filter is parsed into a query at all. + They disagreed on **8 of 19** members: `equals`, `not_equals`, `greater_than`, + `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after`. + + An author could declare any of them, `ViewFilterRuleSchema` validated them, + `defineStack` accepted them — and then `isFilterAST()` refused the filter, the + protocol passed the array through unconverted, and the driver could not apply it. + Six of the eight were reachable only in theory because ObjectUI's adapter alias + table happened to translate them; the safety of the query path was resting on a + hand-written table in another repository being complete, and for `before`/`after` + it wasn't. + + **`AST_OPERATOR_MAP` is now the single source of truth.** `VALID_AST_OPERATORS` + is derived from its keys rather than restated, so an operator can no longer be + accepted by the gate without also having a lowering — the two were separate + hand-written lists that happened to agree, with nothing enforcing it. The map + gained the eight canonical view spellings plus the squashed/short forms stored + metadata carries (`notequals`, `greaterthanorequal`, `eq`, `gt`, …). + + **New export `canonicalAstOperator(op)`** folds every accepted spelling of one + comparison onto a single infix form. Both drivers now call it instead of growing + private alias lists, which is what let them accept different vocabularies. + `like`/`ilike` are deliberately not folded onto `contains`: driver-sql passes them + to SQL verbatim, so folding would silently wrap the value in `%…%`. + + Widening only — no spelling was removed, so no stored filter stops validating. + A filter that previously produced an error (after #4029) or was silently dropped + (before it) now compiles. `filter-view-operator-parity.test.ts` asserts every + `VIEW_FILTER_OPERATORS` member and every `VIEW_FILTER_OPERATOR_ALIASES` key has a + lowering that is a real `$`-operator rather than the `$${op}` fallback, so the + next operator the view layer gains fails a test instead of a query. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/driver-memory/package.json b/packages/plugins/driver-memory/package.json index 24a94928fd..c8b144e987 100644 --- a/packages/plugins/driver-memory/package.json +++ b/packages/plugins/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 77f2aefb09..029099850e 100644 --- a/packages/plugins/driver-mongodb/CHANGELOG.md +++ b/packages/plugins/driver-mongodb/CHANGELOG.md @@ -1,5 +1,524 @@ # @objectstack/driver-mongodb +## 17.0.0-rc.1 + +### Patch Changes + +- b3a2318: fix(driver-memory,driver-mongodb): a bare-day upper bound covers the whole day (#4042) + + The non-SQL half of #3777's calendar-day rule. Both drivers compiled a bare + `YYYY-MM-DD` `$lte` (and a `between` max) as-is, so on timestamp values the + window cut off at the final day's midnight — the dashboard date-range filter's + default configuration (`created_at`, 7 of 13 presets ending "today") lost the + current day, exactly as it did on SQL before #3777 was fixed. + + Both drivers now compile a bare-day upper bound half-open, sharing + `nextUtcCalendarDay` from `@objectstack/core`: + + - `driver-memory`: the Mongo-style and array `where` spellings in the mingo + lowering (`$lte`/`<=` → `$lt` next day; `$between`/`between` max the same), + the analytics cube-filter `lte`, and the analytics `dateRange` window — which + now also matches BOTH stored forms of a timestamp (ISO strings and `Date` + objects) instead of only `Date`s, since mingo compares cross-type as + never-equal. + - `driver-mongodb`: the `translateFilter` lowering, all three spellings + (`$lte`, `$between`, array `<=`/`lte`). + + Unchanged on purpose, matching the #3777 semantics table: full-ISO/`Date` + comparands keep instant semantics, and `$gte`/`$gt`/`$lt` keep their midnight + anchoring. Known remaining gap (tracked separately): values stored as BSON + `Date` (mongodb) or JS `Date` (memory `find()`) never match _string_ comparands + of any operator — a storage-form problem, not a bound-semantics one. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 9e8f04d: fix(driver-memory,driver-mongodb): `Field.datetime` has one storage form per driver (#4047) + + The non-SQL counterpart of ADR-0053 D-B (#3912). Both drivers let the writer + decide a datetime value's runtime type, and both compare across types by type + bracket rather than by value — so a string comparand never matched a `Date` + value, in either direction, for **every** operator including `$gte`. + + A datetime column genuinely held both forms: the drivers' own + `created_at`/`updated_at` defaults bind a `Date` (mongo) or an ISO string + (memory), while REST/JSON writes, relative-date tokens and `initialData` + fixtures supply the other. A dashboard date window therefore answered with + whichever half happened to match the comparand's type — on MongoDB, where + `created_at` is a BSON `Date` and dashboard bounds are strings, that meant + **no rows at all**, which is worse than the final-day loss #3777 fixed. + + Each driver now has one canonical form, applied on write and to every filter + comparand: + + | Driver | `datetime` | `date` | + | ---------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------- | + | `driver-mongodb` | BSON `Date` — the dialect's native instant, its `timestamptz` | `YYYY-MM-DD` text | + | `driver-memory` | canonical UTC ISO text (sorts chronologically under the string comparison mingo performs; survives JSON persistence) | `YYYY-MM-DD` text | + + Both learn their temporal fields from `syncSchema`, so an object that was never + declared is left exactly as written — the drivers do not guess types from + values. `driver-memory` additionally converges rows already in the table when + the schema arrives, which catches `initialData` fixtures and anything a + persistence adapter restored (the in-memory analogue of + `backfillCanonicalDatetimes`, and idempotent like it). + + `Field.date` deliberately stays timezone-naive text on both — converting it to + an instant would invent a midnight and re-couple it to a zone. The + calendar-day bound semantics from #3777/#4042 are unchanged and now compose + with the converged storage: the whole-day rewrite runs on the calendar string + first, and only the resulting bound is converted to the storage form. + +- e59786e: fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) + + A recursive Zod schema needs an explicit annotation to break its circular + inference, and five of them took the cheapest one available: + + ```ts + export const NavigationItemSchema: z.ZodType = z.lazy(() => …); + export type NavigationItem = z.infer; // → any + ``` + + It compiles, it validates correctly at runtime, and it silently throws the type + away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all + `any` on the published surface, plus `FieldNodeSchema` — which had no exported + type alias yet, so `z.infer` was `any` and + `QueryAST['fields']` with it. + + That is worse than a missing export. #4115 tells every consumer that a local + declaration under a spec export's name must be replaced by a binding to the + spec — and for these, obeying it **replaced a precise type with `any`**. + objectui's `NavigationItem` is a 118-line documented interface (`recordId` + template variables, `requiresObject` / `requiresService` capability gates, + `filters` precedence); every key of it exists in the spec's version, so by every + available signal it read as a redundant fork safe to delete. Deleting it swapped + a fully-typed interface for `any`, with no compile error anywhere to say so. + + It is hard to catch by inspection because `any` is mutually assignable with + everything, so the natural "are these the same type?" check answers _yes_ in both + directions and recommends precisely the wrong action. Same failure family as + #4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything + reads as agreement. + + **Now annotated with the real type**, using the pattern `QueryAST` already + follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive + knot in the type, so the keys stay derived from the schema instead of being + hand-maintained beside it: + + ```ts + const BaseXSchema = z.object({ …every non-recursive key }); + export type X = z.infer & { children?: X[] }; + export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + children: z.array(XSchema).optional(), + })); + ``` + + `z.infer` now resolves to the type it should always have been: `NavigationItem` + is the nine-branch discriminated union, `FormField` the 30-key form-field + contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into + `visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` + the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime + validation is unchanged: every schema parses exactly what it parsed before. + + **What the types immediately caught**, none of it visible while they were `any`: + + - `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has + never declared. It worked only because objectui's `NavigationRenderer` still + falls back to that legacy alias. Fixed at the producer per Prime Directive + #12: the canonical key is `expanded`. + - The MongoDB driver built its projection with `projection[field] = 1` over + `query.fields`, so a relationship `FieldNode` would have keyed the projection + on `"[object Object]"`. It now reads the node's field name. + - `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated + with the PARSED `App` / `NavigationContribution` types but omitted + `.default()`ed keys (`expanded`, `target`), as did the form fields + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the + default it was relying on, matching what the surrounding literals already do + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. + + **Gated, not just fixed** (`check:exported-any`, wired into the required + `TypeScript Type Check` job). `api-surface.json` records that an export _exists_ + and never what it _resolves to_, which is how these survived a whole major with + every gate green. The new scan reads the built `.d.ts` a consumer's import + actually resolves to and fails on any exported type that resolves to `any` — or + any exported schema whose output is `any`, the root cause, and the only reason + `FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and + currently empty. It self-tests against the real zod first, so if the internals it + reads are ever renamed the gate fails loudly instead of quietly passing + everything forever. + +- a13827e: fix(data): paging a sorted read is a partition of the result set, not five queries that share a WHERE clause (objectui#3106) + + `ORDER BY status LIMIT 50 OFFSET 50` names a sort key that does not identify a + row, and no backend promises that rows with equal keys keep the same relative + arrangement between two queries. MongoDB documents this outright — `sort` + + `skip`/`limit` on a non-unique key "may return the same document more than + once". So page 2 could repeat a row page 1 already showed and skip one nobody + ever saw: + + ``` + page 1: ORDER BY status LIMIT 5 OFFSET 0 -> [r05 r07 r11 r04 …] + page 2: ORDER BY status LIMIT 5 OFFSET 5 -> [r04 …] r04 again; one row never served + ``` + + Every page is full, every row is real and belongs, and the duplicate sits + several screens from the omission — which is why this is found by a user + counting records, never by reading a response. + + `SqlDriver` and `MongoDBDriver` now append a unique tie-breaker to any non-empty + `orderBy`, in the last requested key's direction (determinism holds either way, + but a same-direction suffix is the one an index can still walk in one pass). + `driver-memory` already conformed — `Array#sort` is stable over a table whose + order does not move — and now has a suite saying so, because that property is + implicit and easy to lose in a refactor that looks like a speed-up. + + `SqlDriver` adds it only for objects it created itself (`initObjects` records + those). A federated table (ADR-0015) may have no `id` column, and guessing there + would be worse than doing nothing: the unknown-column error is answered by + #3821's ladder retrying with **no ORDER BY at all**, trading a reshuffle among + ties for the loss of the caller's whole sort. + + The obligation is now normative on `IDataDriver.find`, with shared cases in + `@objectstack/spec/data` (`PAGINATION_CASES`) that all three drivers run — so a + future driver is held to it by a gate rather than by remembering. + + Not covered by this change: a paged read with **no** `orderBy`. Same defect, + wider blast radius, so it was carved out to #4363 rather than folded in — and + closed there, in the same release. The contract, the shared cases and both + drivers now cover a paged read whatever its `orderBy`, including none at all. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- 6038de7: feat(spec,drivers): the temporal conformance matrix gains its `Field.time` axis — and `time` finally gets a storage form off SQL (ADR-0053 D-A3.2) + + `@objectstack/spec/data` gains `TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES`, + the wall-clock half of the shared matrix. A time gets its own table rather than + a third `kind` on the existing one because it shares no comparand vocabulary + with the other two: no relative token resolves to a wall clock, and the + bare-day whole-day rule (#3777) must **not** reach it — which the table now + asserts rather than assumes, since "the rule leaked into the wrong field type" + is exactly what a conformance matrix is for. The fixture is a business day + carrying the boundaries #3994 measured: both window edges, the pair straddling + the millisecond-suffix width change, midnight and `23:59:59.999`. + + **The axis found a real gap on its first run.** ADR-0053 D-C gave `Field.time` + a canonical form on every SQL dialect, but `driver-memory` and + `driver-mongodb` were never extended — both declared + `TemporalFieldKind = 'datetime' | 'date'`, so a `time` column was never + classified and never coerced. It therefore held whatever each writer produced, + and both stores compare across types by bracket: a text bound matched no + `Date`-written row, in either direction, for every operator. Measured on + `driver-memory`, **8 of the 9 shared cases** returned only the text-written + half — a business-hours window answering `[d_mid, f_close]` instead of + `[c_open, d_mid, e_mid_ms, f_close]`. This is #4047's failure one field type + over, and it survived #4047 because that work extended `datetime` and `date` + without revisiting `time`. On mongo it was also a documentation failure: that + module's canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994, + and nothing implemented it. + + Both drivers now carry `storageTimeValue`, mirroring the SQL + `canonicalTimeOfDay`: `HH:MM:SS`, `.fff` only when the milliseconds are + non-zero, a `Date` / epoch / full-timestamp folding to its **UTC** time-of-day + (never the host's), and totality — an out-of-range wall clock like `'25:00'` + passes through rather than being silently rewritten. Text on both, mongo + included: a wall clock is not an instant, so a BSON `Date` would invent a + calendar day and a zone the author never wrote. + + If you have existing `time` data on either driver, values written as `Date` + objects converge to canonical text on their next write; reads of un-migrated + documents are unchanged. Filters were already unable to reach the mixed half, + so no query that worked before stops working. + +- 8b50cb3: fix(data): a paged read with no `orderBy` is a partition too — the shape every list view actually sends (#4363) + + objectui#3106's server half closed the **sorted** paged read: a non-empty + `orderBy` now carries a unique tie-breaker, so `ORDER BY status LIMIT 50 OFFSET +50` can no longer serve one row twice while never serving another. It stopped + there deliberately. This closes the half it left, which is the more common one. + + A list view whose metadata configures no `sort`, on which nobody has clicked a + column header, sends no `$orderby` at all. `SqlDriver` and `MongoDBDriver` then + emitted a bare `LIMIT`/`OFFSET` — and neither backend promises anything about + the order that slices: + + - **SQL** leaves the row order of an unordered read to the plan. Small tables + hand back insertion order in practice, which is exactly why this survives + testing; a parallel scan, an index scan, or a `VACUUM` need not. + - **MongoDB** returns natural order, which describes where a document currently + sits in its extent — and moves when the document does. + + Every row ties with every other on an empty sort key, so this is the same defect + at full strength rather than a different one: page 2 repeats a row page 1 showed + and drops one nobody sees, with every page full and every row real. + + Both drivers now order a paged read by their unique key column when the caller + supplied no sort keys — the same `id` the tie-breaker was already appending, now + standing alone. `driver-memory` again needed no change: it slices its backing + array, and two reads with no write between them see the identical sequence. The + contract asks for a partition, not for id order. + + **Unpaged reads are untouched, deliberately.** The rule keys off `limit`/ + `offset`, not off `orderBy` being absent. A read with neither hands back the + whole matching set, so no caller can be shown a partial view of it, and sorting + every read in the system would change plan selection to buy nothing. `limit` + alone does count as paged: page one of a walk is routinely `limit=50` with no + offset, and ordering only the later pages would leave the defect fully intact. + + `SqlDriver` keeps the existing restriction to objects it created itself + (`initObjects` records them). It matters more here than for the sorted case: on + a federated table (ADR-0015) there is no requested sort for #3821's ladder to + fall back to, so a wrong guess about `id` would turn a reshuffle into a failed + read. Those tables now get a warning — once per object, behavior unchanged — + because the contract states determinism as a MUST, and a MUST that quietly does + not hold is the same invisible failure the rule was written against. + + `findOne` is deliberately outside all of this, and the contract now says so. + Engines reach a driver with `limit: 1`, which is shaped exactly like page one of + a walk, but it promises _a_ matching record rather than a position in a + sequence — nothing for a second call to be inconsistent with. Reading it as a + page would put `ORDER BY id LIMIT 1` on the hottest read in the system, which is + the classic shape for a planner to abandon the predicate's own index: measured + on Postgres 16 over 2M rows, `WHERE owner_id = ? LIMIT 1` went 0.08 ms → 7.8 ms + and swapped the `owner_id` index for the primary key. `MongoDBDriver.findOne` + has never sorted, so this also puts the two drivers back in step. + + The obligation is normative on `IDataDriver.find` and the cases are shared — + `PAGINATION_UNORDERED_CASES` alongside `PAGINATION_CASES` in + `@objectstack/spec/data` — so a future driver is held to both halves by a gate + rather than by remembering. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json index 1f303ee28b..eb0c26d5bc 100644 --- a/packages/plugins/driver-mongodb/package.json +++ b/packages/plugins/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 669a079c2b..5d3c509de4 100644 --- a/packages/plugins/driver-sql/CHANGELOG.md +++ b/packages/plugins/driver-sql/CHANGELOG.md @@ -1,5 +1,846 @@ # @objectstack/driver-sql +## 17.0.0-rc.1 + +### Major Changes + +- 2d3e255: feat!: ADR-0113 — `required` is a write contract; the column constraint becomes the explicit `storage.notNull` + + `field.required` bound three meanings to one knob (write check, `NOT NULL` DDL, + drift expectation), so tightening any invariant on a deployed object was a + destructive migration blocked by the very legacy nulls that motivated it — the + reason `criteria_json`'s mandatory-in-substance contract lived in three + imperative guards instead of one declaration. + + Split, with the **non-regression invariant** as the unifying rule — _a write + may not take a record from compliant to violating; a pre-existing violation + does not block writes that leave it in place_: + + - `required: true` = the write contract, uniformly on new and deployed objects: + insert must provide; **an update PATCHing `null` into a required field is now + rejected** (it silently passed before); omitted fields never block, so legacy + null rows rest. The column stays nullable. + - `storage: { notNull: true }` = the explicit physical constraint, owning the + DDL (`sql-driver` `createColumn`) and the destructive drift ceremony. + Orthogonal to `required` — all four combinations are legitimate, including + the engine-populated column (`storage.notNull` without `required`). + - `requiredWhen` inherits the same invariant: flipping the condition true + without providing the field is rejected (the write _creates_ the violation); + a row violating since before the rule tightened no longer locks out + unrelated edits (#3929's objection, cured). `storage.notNull` × + `requiredWhen` rejects at parse (`FieldSchema.superRefine`). + - **Pre-17 sources keep their exact meaning** via the migration-chain-only + `field-required-notnull-explicit` conversion: `os migrate meta` stamps + `storage.notNull` onto every previously-required field — writing down what + the old text already meant. The loader never infers semantics from the + physical column. + - Drift compares nullability against `storage.notNull`; a column stricter than + its declaration is `needs_confirm` (never auto-applied — dev auto-reconcile + no longer silently strips a stray `NOT NULL`), and silent when the field is + write-gated by `required`. + +### Minor Changes + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +- c8124e5: fix(driver-sql): give `Field.datetime` one UTC storage form per dialect (#3912, #3942) + + Any window filter on a `Field.datetime` column returned an empty set on SQLite — + a dashboard `dateRange: last_30_days` on `created_date` read 0 while 29 matching + rows existed. + + There was never a storage _convention_, only a description of what better-sqlite3 + happened to do with a bound JS `Date`. Nothing enforced it — `formatInput` + deliberately left `datetime` untouched — so the form was decided by whichever + writer got there first: a JS `Date` landed as INTEGER epoch ms, while a REST/JSON + write (JSON has no `Date` type), a `defaultValue: 'NOW()'` slot, and the + platform's own `created_at` / `updated_at` all landed as ISO **TEXT**. One column + held both forms while the read path coerced comparands to epoch ms purely from + the _declared_ type. On SQLite's type ordering (`INTEGER < TEXT`) a two-sided + window collapsed to zero rows, and a one-sided `>=` matched every TEXT row + regardless of the bound. + + `Field.datetime` now has one canonical instant per dialect, produced by one + function applied on write **and** to every filter comparand, so the two sides of + a comparison cannot disagree about shape: + + - **SQLite** — `YYYY-MM-DDTHH:MM:SS.sssZ` text. Lexicographic order _is_ + chronological order, so range filters and `ORDER BY` read the column directly + and can use an index; `strftime` parses it, so the date-bucket expression needs + no CASE. + - **Postgres** — `timestamptz`, unchanged. The fix here is on the write and + comparand side: a zone-naive write was previously resolved against the + _server's_ timezone (measured 8 hours off on `Asia/Shanghai`), and an + un-anchored `YYYY-MM-DD` comparand meant the server's local midnight, so the + identical query over the identical instant landed a row on a different calendar + day than SQLite did. + - **MySQL** — `DATETIME(3)` instead of `TIMESTAMP`, a connection pinned to UTC on + both the mysql2 and the server layer, and a MySQL-spelled bind carrying the + same UTC wall clock. MySQL accepts neither the `T` separator nor the `Z` suffix + in a datetime literal, so datetime writes over REST had always failed outright; + `TIMESTAMP` additionally truncated milliseconds and could not store an instant + outside 1970..2038. + + Existing rows converge at schema sync. Both migrations are allowed to fail: they + log, mark nothing, and the read paths keep a repair expression, so an un-migrated + column still compares and buckets **correctly** — just unindexed. Neither can + repair instants the old timezone-ambiguous write path recorded wrongly; they + preserve what is on disk. + + Also closes #3928 (datetime `ORDER BY` mis-sorted on mixed storage) by + construction. Rationale is recorded as ADR-0053 addendum D-B1..D-B4. + + The analytics change is additive: a `coerceTemporalFilterColumn` companion to the + existing `coerceTemporalFilterValue` hook, so a raw-SQL strategy can normalise the + column side too. Absent hook → byte-identical SQL. + +- 9774b78: fix(driver-sql): `Field.time` gets a canonical storage form — `HH:MM:SS[.fff]` wall-clock text on every dialect (#3994) + + `Field.time` repeated the pre-#3912 `Field.datetime` pattern: writes were never + normalised and only reads were repaired, so one SQLite column accumulated bare + time-of-day TEXT, full-timestamp TEXT and INTEGER epoch ms side by side. + `find()` looked right; everything that compared the STORED form was wrong — + measured: a business-hours window filter silently dropped 4 of 7 rows, ORDER BY + sorted 14:30 before 08:00, a full-ISO write failed the statement outright on + both Postgres and MySQL, a bound `Date` stored a process-timezone wall clock on + pg, MySQL's bare `TIME` rounded `…00.500` up to `…01`, and a `NOW()` default + resolved against three different clocks on the three dialects. + + The #3912→#3942→#3954 construction, transplanted (ADR-0053 D-C1..D-C3): + + - One `canonicalTimeOfDay` — `HH:MM:SS`, `.fff` only when non-zero; `Date`/ + epoch/full-timestamp fold to the UTC time-of-day — applied on write + (`formatInput`), to filter comparands (`coerceFilterValue`, and thereby the + `temporalFilterValue` contract hook) and on read (`toTimeOnly`). + - SQLite: legacy columns converge at schema sync (`backfillCanonicalTimes`, + same `IS NOT`-guarded UPDATE, same log-and-swallow policy); until then the + filter paths wrap the column in the repair expression — correct, just + unindexed. `os migrate plan` lists the work as `normalize_time_storage` with + a row count. + - MySQL: new time columns are `TIME(3)`; legacy `TIME(0)` columns widen at + schema sync (`migrateMysqlTimeColumns`, plan kind `widen_time_columns`), + since zero-precision TIME _rounds_ fractional writes. + - `NOW()` defaults read the UTC clock on every dialect (Postgres previously + used the server zone, MySQL the inserting session's zone — and MySQL 8.0 + rejects a plain `CURRENT_TIMESTAMP` default on TIME entirely). + - `distinct()`/`aggregate()` present time columns exactly as `find()` does. + + `HH:MM:SS` writes round-trip byte-identically (the field-zoo `f_time` + contract); a minutes-only `HH:MM` now completes to `HH:MM:00`, and uninterpretable + values still pass through untouched. + +- 33a5ff4: `os migrate` no longer touches the database before you confirm, and refuses a + SQLite database another process is using (#3917). + + **Nothing is written before the prompt.** `plan` called itself a dry run and + `apply` gated on `[y/N]`, but both booted the full plugin set first — and boot + schema-sync issued create-table/add-column DDL (plus the artifact's inline seed + wrote rows) against the target database before either promise was kept. + `SqlDriver` gains `setDeferredDdl` / `previewDeferredSchemaWork` / + `flushDeferredSchemaDdl`: while armed, `initObjects` still registers every + in-memory map drift detection depends on but records the physical work instead + of performing it. Both commands boot with it armed, render the held-back work + as a `New (additive)` section of the plan, and `apply` performs it only after + confirmation. `os meta resync` / `os migrate files-to-references` keep the old + behaviour — they need the tables to exist. + + **Occupancy check.** A live `os dev`/`os serve` holding the same SQLite file is + the usual way a migration goes wrong: the migration is transactional and swaps + tables inside the file, but the running server keeps prepared statements and a + schema cookie the migration invalidates. `os migrate` now probes the target + before booting — `PRAGMA locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` under + `busy_timeout = 0`, which reports `SQLITE_BUSY` when another connection is + _attached_, not merely writing. (`wal_checkpoint(TRUNCATE)` only sees an active + writer, and `-wal`/`-shm` presence cannot tell a live server from a crashed one; + both are encoded as tests.) `apply` refuses with exit 1 — `error: database_busy` + under `--json` — unless the new `--force` flag is passed; `plan` warns and + continues, since it writes nothing either way. SQLite only: Postgres and MySQL + take their own server-side locks. + + `@objectstack/runtime` also exports `resolveStandaloneDatabase()`, so a caller + can resolve the database target with the same precedence the boot uses without + building the stack, and `createStandaloneStack` accepts `skipSeedData`. + +- 9e01213: fix(cli,driver-sql): `os migrate plan` lists the datetime storage convergence (#3954) + + The datetime canonicalisation (#3912/#3942) added two steps to `initObjects`' + physical path: a row-rewriting backfill on SQLite and a `TIMESTAMP` → + `DATETIME(3)` column rebuild on MySQL. Both already respected the DDL deferral, + so `plan` performed neither and `apply` performed both — the behaviour was never + wrong. The reporting was. + + `PendingSchemaWork` could only express `create_table` / `add_columns`, so an + operator saw a plan listing two added columns, confirmed it, and `apply` + additionally rewrote every row of a datetime column — or took a metadata lock to + rebuild one on a large table. The plan promises to show what apply will do. + + - `PendingSchemaWork.kind` gains `normalize_datetime_storage` and + `widen_datetime_columns`, plus an optional `rows` carrying how much data the + step touches: row-writes for the backfill, the table's size for the rebuild — + the number that decides "now" versus "in a maintenance window". + - `previewDeferredSchemaWork()` measures both without performing either, reusing + the exact predicate each migration uses (the backfill's whole `WHERE`, the + widening's own `information_schema` filter) so the plan and the apply cannot + name different sets. A probe that cannot run is swallowed to "unlisted", never + to a failed plan. + - The CLI renders them under their own heading rather than folding them into the + additive section, whose "created when you apply" framing carries an implicit + promise that the work is never data-losing. `summarizePendingSchemaWork` — the + line read just before typing `y` — never omits in-place work. + +- c53aa53: File-backed SQLite now runs `journal_mode = WAL` (#3941). + + `SqlDriver.connect()` set `auto_vacuum` and left the journal mode alone, so + every ObjectStack SQLite database ran SQLite's built-in default — a rollback + journal. That is the worst mode for the shape this platform actually has, which + is **several processes on one file**: a dev server, `os migrate`, + `os meta resync`, a test run. Measured, on the same file: + + | | rollback journal | WAL | + | :--------------------------------------------- | :------------------------------------------------- | :---------------------------------------------------------------- | + | writer while another process holds a read open | `SQLITE_BUSY` — committing needs an exclusive lock | proceeds | + | idle attached connection visible to SQL | no — a lock lasts only as long as its transaction | yes (`locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` reports busy) | + + The second row is why the `os migrate` occupancy check had to inspect file + descriptors to see a live server at all (#3940): under a rollback journal there + was nothing in the database to see. That signal stays — it names the process, + which WAL's lock probe cannot — but the SQL probe is now authoritative for + databases ObjectStack created rather than a fallback that was blind in practice. + Concurrent _writers_ still serialize; SQLite allows one at a time in any mode. + + Journal mode is a persistent property of the file, so an existing database is + converted in place on the next connect (a header change — no rows are touched) + and stays converted. Two consequences to plan for: + + - `app.db-wal` / `app.db-shm` exist beside the database while a connection is + attached, and `app.db-wal` can hold committed transactions. A clean shutdown + checkpoints them away; a naive copy of `app.db` alone while a server runs does + not. Use `sqlite3 app.db ".backup …"`. + - **WAL does not work on network filesystems** (NFS/SMB). Opt out with + `OS_DATABASE_SQLITE_JOURNAL_MODE=delete`, or per datasource with + `sqliteJournalMode: 'delete'` in the driver config (which outranks the env + var). Either form _applies_ `delete`, so it also converts a database that + already adopted WAL back — skipping would have stranded it. + + Nothing here fails a boot, and nothing is assumed: `PRAGMA journal_mode = X` + answers with the mode actually in force rather than raising on refusal, so the + reply is read back; and because a filesystem can accept WAL and then fail the + first read _through_ it, the mode is proven with a read and rolled back to + `delete` if that fails — with a warning naming the file and the escape hatch. + `synchronous` is untouched, so durability is exactly what it was. `:memory:` + databases are left alone, as is `auto_vacuum = INCREMENTAL`, which keeps + reclaiming under WAL (ADR-0057). + + `os db clean` now counts `-wal` / `-shm` as part of the database when it measures + what a `VACUUM` reclaimed, so bytes that were sitting in the log do not read as a + reclaim of zero. + + `@objectstack/driver-sqlite-wasm` deliberately stays out of WAL. Its live + database is in the WASM heap and what reaches disk is a byte image it exports, so + nothing reads the database across processes and the pragma buys it nothing — + while still being a persistent header change in the operator's file. sql.js + _accepts_ the pragma (its VFS is memory-backed), so this had to be declared + rather than discovered. + + It also now parks a `-wal` left behind by an unclean native-driver exit rather + than loading the image beside it: wasm SQLite cannot read that log, and leaving + it next to a freshly rewritten image would let a later real SQLite replay frames + that no longer belong to it. The warning names the file it parked and how to + recover what was in it. + +### Patch Changes + +- 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777) + + A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a + lower bound and was silently wrong for an upper one: the dashboard date-range + filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a + `datetime` column every row created after 00:00 of the `to` day vanished from + the result — no error, the chart renders, the numbers are just smaller. The + default configuration hit it: the filter's default field is `created_at` + (a system-injected `Field.datetime`) and 7 of the 13 presets end "today". + + The translation is operator-sensitive and half-open, applied at every + comparison emitter: + + - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a + bare-day comparand on a `datetime` column compiles to `< next-day-midnight` + in the column's storage form; `$between [min, max]` with a bare-day max + decomposes to `>= min AND < next-day(max)`. Both the plain and the + legacy-repair (mixed-storage) column paths, both `where` spellings. + - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day` + instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day. + - The `/analytics/sql` rendering and the dataset preview evaluator apply the + same rule, so the echoed SQL and drafted numbers reproduce execution. + + `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`: + the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants, + `Date`s and impossible days are never widened). + + Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns + (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands + (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for + those). No authored metadata changes: a dashboard's existing + `{ $gte, $lte }` window now simply includes its final day. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 42e3b01: fix(driver-sql): `Field.date` + `defaultValue: 'NOW()'` records the UTC calendar day on Postgres/MySQL (#4022) + + The bare `CURRENT_TIMESTAMP` default resolved the calendar day in the SERVER's + timezone on Postgres — measured: a UTC-12 server recorded yesterday; an + Asia/Shanghai server records tomorrow for every default after 16:00 UTC — and + MySQL 8.0 rejects it on a DATE column outright (MariaDB is merely permissive, + and the driver's UTC-pinned session masked the semantic half there). + `nowColumnDefault` now emits a UTC expression default on both dialects, the + #3994 D-C3 construction one type over. Defaults only govern newly created + columns; existing columns keep their legacy default, per the standing D-B3 + policy. + +- 39eb01b: fix(driver-sql): a currently-declared unique index is never legacy debt — index drift no longer ping-pongs (#3955) + + An object may declare both a tenant-scoped field-level `unique: true` and an + object-level single-column unique index on the same column: + + ```ts + email: Field.email({ unique: true }), + indexes: [{ fields: ['email'], unique: true }], + ``` + + The declared index materializes under `buildIndexName` as + `uniq__` — which is also one of the two spellings + `legacyUniqueIndexNames` looks for when hunting pre-#3696 platform-wide + uniques. The detector therefore read an index the current metadata declares + as legacy debt and proposed replacing it with the tenant composite (which + the same sync had already created). + + The resulting plan never converged: `apply` dropped the declared index, the + next `plan` reported it missing and recreated it, and the one after that + called it legacy again — an unbounded drop/create cycle on a live unique + index, every round rendered as a "safe" change. + + `legacyUniqueReplacements` now takes the object's `declaredIndexes` and + filters their normalized names out of the legacy candidate set, so an index + metadata declares today is never mistaken for debt. Genuinely legacy indexes + are still retired, including the knex-spelled `
__unique` when + only the `uniq_…` spelling is declared. + +- 4384921: fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311) + + Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever + read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors, + and most of what looked like sloppy test fixtures was the types being wrong. + + **`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a + long time without being on the schema: `SqlDriver.auditMissingTenant` reads it + to suppress the "tenant-scoped write without `tenantId`" warning, the driver's + own warning text tells callers to set it, `ObjectQLEngine` sets it for + system-context calls, and `service-settings` / `service-datasource` pass it on + every global-scope write. Because the schema never had it, the driver read it + through `(options as any)` and no caller was type-checked. The declaration + states the limit as well: it silences a diagnostic and MUST NOT change which + rows a write touches — suppressing an audit warning is not a permission. + + The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`, + all long since declared. Those reads now go through `DriverOptions`, so the next + undeclared option fails the build instead of hiding behind an existing cast. + + **`SqlDriver.findOne(object, id)` is removed.** An undeclared + `typeof query === 'string' | 'number'` branch accepted a bare id. It was on no + contract, nothing outside that package's own tests used it, and the other two + drivers answered the identical call differently — `MemoryDriver` spreads the + string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined` + and returns an arbitrary row. It also bypassed the shared `findRows()` path, so + it skipped field selection, temporal coercion, unknown-column recovery and the + `singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is: + + ```ts + -(await driver.findOne("task", "t1")); + +(await driver.findOne("task", { object: "task", where: { id: "t1" } })); + ``` + + **`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is + fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the + tenant column and to set or clear the sticky explicit-opt-out — but the + parameter type listed only `{ name, fields }`, so a caller that spelled the key + correctly was rejected while the driver read it anyway. + `registerExternalObject` already had it. + + **`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is + `.default('UTC')`, so the parsed type requires it and an authored literal does + not have it — the same two-tier split `QueryInput`/`QueryAST` already names on + the query side. `InMemoryDriver.create`/`bulkCreate` also declare their + `IDataDriver` return types; without them TS inferred the literal the method + builds and every other column of the created row disappeared from the caller's + view. + + One silent runtime bug fell out of the same pass: a driver test asked for + `orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and + the sort never reached SQL. The tuple spelling appears nowhere else. + +- 6f98c2d: fix(driver-sql,driver-memory): an uncompilable filter now throws instead of matching everything (#3948) + + A filter the driver could not compile was **skipped**, not rejected. No predicate + was emitted and the query returned every row — the caller asked to filter and + silently received the unfiltered set. + + The reachable shape is a bare comparison triple. `['close_date','before','2024-01-01']` + arrives at a driver only when `isFilterAST()` refused it — its operator is outside + `VALID_AST_OPERATORS`, so `parseFilterAST()` never converted it and the raw array + was assigned to `where`. `driver-sql`'s loop then saw three _strings_, matched + neither `and` nor `or`, and `continue`d past all three. `driver-memory` was worse: + it cast every string to a logic keyword, opening three empty groups and returning + `{}` — a filter matching every record. + + This is reachable from ordinary authoring, not just malformed input: `before` and + `after` are canonical `VIEW_FILTER_OPERATORS` members that `VALID_AST_OPERATORS` + does not accept. Eight of the nineteen canonical view operators are in that + position, including `equals`; the others were masked only because ObjectUI's + adapter alias table happened to cover them. + + **Behaviour change.** Both drivers now throw on a filter element that is neither a + logical keyword (`and`/`or`) nor a condition array, and `driver-memory` throws on + an operator it cannot express rather than dropping the condition. The nested and + `$`-object paths already threw on the same input, so this makes the three paths + agree. A caller that was relying on the old silence was receiving wrong results; + the error names the operator and the offending filter. + + **`driver-memory` also gains seven operators it silently ignored:** `not_in`, + `is_null`, `is_not_null`, `isnull`, `isnotnull`, `is_empty`, `is_not_empty` — all + members of `VALID_AST_OPERATORS`, all previously falling through to + `default: return null`. `is_null` narrowed nothing instead of matching null rows. + Alias sets and semantics mirror `driver-sql`'s `whereNull`/`whereNotNull` arms so + the two backends accept one vocabulary. + + Migration: none for well-formed filters. If a query now throws, the filter was + never being applied — fix the operator (the message names it), or lower it to an + AST spelling. `before` → `<`, `after` → `>`, `'not in'` → `nin`. + +- a13827e: fix(data): paging a sorted read is a partition of the result set, not five queries that share a WHERE clause (objectui#3106) + + `ORDER BY status LIMIT 50 OFFSET 50` names a sort key that does not identify a + row, and no backend promises that rows with equal keys keep the same relative + arrangement between two queries. MongoDB documents this outright — `sort` + + `skip`/`limit` on a non-unique key "may return the same document more than + once". So page 2 could repeat a row page 1 already showed and skip one nobody + ever saw: + + ``` + page 1: ORDER BY status LIMIT 5 OFFSET 0 -> [r05 r07 r11 r04 …] + page 2: ORDER BY status LIMIT 5 OFFSET 5 -> [r04 …] r04 again; one row never served + ``` + + Every page is full, every row is real and belongs, and the duplicate sits + several screens from the omission — which is why this is found by a user + counting records, never by reading a response. + + `SqlDriver` and `MongoDBDriver` now append a unique tie-breaker to any non-empty + `orderBy`, in the last requested key's direction (determinism holds either way, + but a same-direction suffix is the one an index can still walk in one pass). + `driver-memory` already conformed — `Array#sort` is stable over a table whose + order does not move — and now has a suite saying so, because that property is + implicit and easy to lose in a refactor that looks like a speed-up. + + `SqlDriver` adds it only for objects it created itself (`initObjects` records + those). A federated table (ADR-0015) may have no `id` column, and guessing there + would be worse than doing nothing: the unknown-column error is answered by + #3821's ladder retrying with **no ORDER BY at all**, trading a reshuffle among + ties for the loss of the caller's whole sort. + + The obligation is now normative on `IDataDriver.find`, with shared cases in + `@objectstack/spec/data` (`PAGINATION_CASES`) that all three drivers run — so a + future driver is held to it by a gate rather than by remembering. + + Not covered by this change: a paged read with **no** `orderBy`. Same defect, + wider blast radius, so it was carved out to #4363 rather than folded in — and + closed there, in the same release. The contract, the shared cases and both + drivers now cover a paged read whatever its `orderBy`, including none at all. + +- 3fe0ff1: fix(driver-sql): `os migrate plan` no longer promises columns the apply can never create (#3978) + + `previewDeferredSchemaWork()` listed every declared field name when computing + pending `create_table` / `add_columns` work, but `createColumn` returns early + for a virtual `formula` field — no column is ever created for it. + + So a formula field showed up as pending `add_columns` that `apply` reported as + performed without doing anything, and the very next `plan` reported it again. + A freshly-applied database looked permanently un-migrated, with no invocation + able to clear the finding. On `examples/app-crm` that was 4 columns + (`crm_contact.full_name`, `crm_lead.is_closed`, `crm_opportunity.expected_revenue`, + `crm_opportunity.days_to_close`) reported forever. + + The preview now filters through `fieldHasColumn` — the same helper `createColumn` + and the column differ already answer "does this field materialize a column?" + with — so the plan and the flush cannot disagree. `multiple` fields are + unaffected: they materialize as a JSON column and are still reported. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- 8b50cb3: fix(data): a paged read with no `orderBy` is a partition too — the shape every list view actually sends (#4363) + + objectui#3106's server half closed the **sorted** paged read: a non-empty + `orderBy` now carries a unique tie-breaker, so `ORDER BY status LIMIT 50 OFFSET +50` can no longer serve one row twice while never serving another. It stopped + there deliberately. This closes the half it left, which is the more common one. + + A list view whose metadata configures no `sort`, on which nobody has clicked a + column header, sends no `$orderby` at all. `SqlDriver` and `MongoDBDriver` then + emitted a bare `LIMIT`/`OFFSET` — and neither backend promises anything about + the order that slices: + + - **SQL** leaves the row order of an unordered read to the plan. Small tables + hand back insertion order in practice, which is exactly why this survives + testing; a parallel scan, an index scan, or a `VACUUM` need not. + - **MongoDB** returns natural order, which describes where a document currently + sits in its extent — and moves when the document does. + + Every row ties with every other on an empty sort key, so this is the same defect + at full strength rather than a different one: page 2 repeats a row page 1 showed + and drops one nobody sees, with every page full and every row real. + + Both drivers now order a paged read by their unique key column when the caller + supplied no sort keys — the same `id` the tie-breaker was already appending, now + standing alone. `driver-memory` again needed no change: it slices its backing + array, and two reads with no write between them see the identical sequence. The + contract asks for a partition, not for id order. + + **Unpaged reads are untouched, deliberately.** The rule keys off `limit`/ + `offset`, not off `orderBy` being absent. A read with neither hands back the + whole matching set, so no caller can be shown a partial view of it, and sorting + every read in the system would change plan selection to buy nothing. `limit` + alone does count as paged: page one of a walk is routinely `limit=50` with no + offset, and ordering only the later pages would leave the defect fully intact. + + `SqlDriver` keeps the existing restriction to objects it created itself + (`initObjects` records them). It matters more here than for the sorted case: on + a federated table (ADR-0015) there is no requested sort for #3821's ladder to + fall back to, so a wrong guess about `id` would turn a reshuffle into a failed + read. Those tables now get a warning — once per object, behavior unchanged — + because the contract states determinism as a MUST, and a MUST that quietly does + not hold is the same invisible failure the rule was written against. + + `findOne` is deliberately outside all of this, and the contract now says so. + Engines reach a driver with `limit: 1`, which is shaped exactly like page one of + a walk, but it promises _a_ matching record rather than a position in a + sequence — nothing for a second call to be inconsistent with. Reading it as a + page would put `ORDER BY id LIMIT 1` on the hottest read in the system, which is + the classic shape for a planner to abandon the predicate's own index: measured + on Postgres 16 over 2M rows, `WHERE owner_id = ? LIMIT 1` went 0.08 ms → 7.8 ms + and swapped the `owner_id` index for the primary key. `MongoDBDriver.findOne` + has never sorted, so this also puts the two drivers back in step. + + The obligation is normative on `IDataDriver.find` and the cases are shared — + `PAGINATION_UNORDERED_CASES` alongside `PAGINATION_CASES` in + `@objectstack/spec/data` — so a future driver is held to both halves by a gate + rather than by remembering. + +- 0166bd5: fix(spec,drivers): the view filter vocabulary and the AST vocabulary now agree (#3948) + + `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a + `ViewFilterRule`. `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates + `isFilterAST()`, which decides whether a filter is parsed into a query at all. + They disagreed on **8 of 19** members: `equals`, `not_equals`, `greater_than`, + `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after`. + + An author could declare any of them, `ViewFilterRuleSchema` validated them, + `defineStack` accepted them — and then `isFilterAST()` refused the filter, the + protocol passed the array through unconverted, and the driver could not apply it. + Six of the eight were reachable only in theory because ObjectUI's adapter alias + table happened to translate them; the safety of the query path was resting on a + hand-written table in another repository being complete, and for `before`/`after` + it wasn't. + + **`AST_OPERATOR_MAP` is now the single source of truth.** `VALID_AST_OPERATORS` + is derived from its keys rather than restated, so an operator can no longer be + accepted by the gate without also having a lowering — the two were separate + hand-written lists that happened to agree, with nothing enforcing it. The map + gained the eight canonical view spellings plus the squashed/short forms stored + metadata carries (`notequals`, `greaterthanorequal`, `eq`, `gt`, …). + + **New export `canonicalAstOperator(op)`** folds every accepted spelling of one + comparison onto a single infix form. Both drivers now call it instead of growing + private alias lists, which is what let them accept different vocabularies. + `like`/`ilike` are deliberately not folded onto `contains`: driver-sql passes them + to SQL verbatim, so folding would silently wrap the value in `%…%`. + + Widening only — no spelling was removed, so no stored filter stops validating. + A filter that previously produced an error (after #4029) or was silently dropped + (before it) now compiles. `filter-view-operator-parity.test.ts` asserts every + `VIEW_FILTER_OPERATORS` member and every `VIEW_FILTER_OPERATOR_ALIASES` key has a + lowering that is a real `$`-operator rather than the `$${op}` fallback, so the + next operator the view layer gains fails a test instead of a query. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index 3150c1d7a1..0b22214418 100644 --- a/packages/plugins/driver-sql/package.json +++ b/packages/plugins/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ef92e45276..09eff5c68b 100644 --- a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/plugins/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,369 @@ # @objectstack/driver-sqlite-wasm +## 17.0.0-rc.1 + +### Patch Changes + +- 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777) + + A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a + lower bound and was silently wrong for an upper one: the dashboard date-range + filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a + `datetime` column every row created after 00:00 of the `to` day vanished from + the result — no error, the chart renders, the numbers are just smaller. The + default configuration hit it: the filter's default field is `created_at` + (a system-injected `Field.datetime`) and 7 of the 13 presets end "today". + + The translation is operator-sensitive and half-open, applied at every + comparison emitter: + + - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a + bare-day comparand on a `datetime` column compiles to `< next-day-midnight` + in the column's storage form; `$between [min, max]` with a bare-day max + decomposes to `>= min AND < next-day(max)`. Both the plain and the + legacy-repair (mixed-storage) column paths, both `where` spellings. + - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day` + instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day. + - The `/analytics/sql` rendering and the dataset preview evaluator apply the + same rule, so the echoed SQL and drafted numbers reproduce execution. + + `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`: + the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants, + `Date`s and impossible days are never widened). + + Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns + (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands + (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for + those). No authored metadata changes: a dashboard's existing + `{ $gte, $lte }` window now simply includes its final day. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 9881074: test(drivers): the "held to by a gate" claim now has a gate behind it (#4363) + + Three changesets — filter combinator semantics (#3774), temporal storage form + (ADR-0053), deterministic paged reads (objectui#3106 / #4363) — each introduced + a shared case-set in `@objectstack/spec/data` with some version of the claim + that a future driver "is held to this by a gate rather than by remembering it". + + There was no gate. The case-sets are exports sitting in a package; nothing + obliged a driver to import them. Measured on `main`, the matrix had three holes: + `driver-sqlite-wasm` ran neither pagination case-set, and neither it nor + `driver-mongodb` ran the filter-logic one — including a hole in the very + case-set whose changeset made the claim. + + `scripts/check-driver-conformance.mjs` (`pnpm check:driver-conformance`, wired + into lint.yml's required job) makes the hole the failure. Every + (driver × case-set) cell is covered — some file under the package's `src/` + imports _and drives_ the case-set's marker export — or carries a measured + DEBT/EXEMPT entry, reconciled in both directions. A third direction, CLASSIFIED, + holds the other end: a new `*-conformance.ts` fixture nobody classified fails + the run rather than starting life uncovered, which is the direction that + actually rots (#4203). It caught an unclassified `TEMPORAL_TIME_CASES` on its + first run. + + `driver-sqlite-wasm` gains the pagination suite the gate found missing. It + inherits `SqlDriver`'s ORDER BY construction, so nothing is re-implemented — + what the suite pins is that the clause survives a different _engine_: this + driver swaps knex's transport for a custom sql.js dialect that compiles, + executes and marshals every row through its own path, and a dialect that + reordered or dropped the trailing `ORDER BY id` would fail in no other suite. + + The two filter-logic holes are ledgered as DEBT rather than fixed here, with + their reasons printed on every run and tracked in #4405. The mongodb row is the + substantive one: `translateFilter` is an independent FilterCondition backend — + the fifth, and the one #3774 never enrolled when it counted "the four". + +- 4384921: fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311) + + Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever + read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors, + and most of what looked like sloppy test fixtures was the types being wrong. + + **`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a + long time without being on the schema: `SqlDriver.auditMissingTenant` reads it + to suppress the "tenant-scoped write without `tenantId`" warning, the driver's + own warning text tells callers to set it, `ObjectQLEngine` sets it for + system-context calls, and `service-settings` / `service-datasource` pass it on + every global-scope write. Because the schema never had it, the driver read it + through `(options as any)` and no caller was type-checked. The declaration + states the limit as well: it silences a diagnostic and MUST NOT change which + rows a write touches — suppressing an audit warning is not a permission. + + The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`, + all long since declared. Those reads now go through `DriverOptions`, so the next + undeclared option fails the build instead of hiding behind an existing cast. + + **`SqlDriver.findOne(object, id)` is removed.** An undeclared + `typeof query === 'string' | 'number'` branch accepted a bare id. It was on no + contract, nothing outside that package's own tests used it, and the other two + drivers answered the identical call differently — `MemoryDriver` spreads the + string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined` + and returns an arbitrary row. It also bypassed the shared `findRows()` path, so + it skipped field selection, temporal coercion, unknown-column recovery and the + `singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is: + + ```ts + -(await driver.findOne("task", "t1")); + +(await driver.findOne("task", { object: "task", where: { id: "t1" } })); + ``` + + **`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is + fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the + tenant column and to set or clear the sticky explicit-opt-out — but the + parameter type listed only `{ name, fields }`, so a caller that spelled the key + correctly was rejected while the driver read it anyway. + `registerExternalObject` already had it. + + **`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is + `.default('UTC')`, so the parsed type requires it and an authored literal does + not have it — the same two-tier split `QueryInput`/`QueryAST` already names on + the query side. `InMemoryDriver.create`/`bulkCreate` also declare their + `IDataDriver` return types; without them TS inferred the literal the method + builds and every other column of the created row disappeared from the caller's + view. + + One silent runtime bug fell out of the same pass: a driver test asked for + `orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and + the sort never reached SQL. The tuple spelling appears nowhere else. + +- c53aa53: File-backed SQLite now runs `journal_mode = WAL` (#3941). + + `SqlDriver.connect()` set `auto_vacuum` and left the journal mode alone, so + every ObjectStack SQLite database ran SQLite's built-in default — a rollback + journal. That is the worst mode for the shape this platform actually has, which + is **several processes on one file**: a dev server, `os migrate`, + `os meta resync`, a test run. Measured, on the same file: + + | | rollback journal | WAL | + | :--------------------------------------------- | :------------------------------------------------- | :---------------------------------------------------------------- | + | writer while another process holds a read open | `SQLITE_BUSY` — committing needs an exclusive lock | proceeds | + | idle attached connection visible to SQL | no — a lock lasts only as long as its transaction | yes (`locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` reports busy) | + + The second row is why the `os migrate` occupancy check had to inspect file + descriptors to see a live server at all (#3940): under a rollback journal there + was nothing in the database to see. That signal stays — it names the process, + which WAL's lock probe cannot — but the SQL probe is now authoritative for + databases ObjectStack created rather than a fallback that was blind in practice. + Concurrent _writers_ still serialize; SQLite allows one at a time in any mode. + + Journal mode is a persistent property of the file, so an existing database is + converted in place on the next connect (a header change — no rows are touched) + and stays converted. Two consequences to plan for: + + - `app.db-wal` / `app.db-shm` exist beside the database while a connection is + attached, and `app.db-wal` can hold committed transactions. A clean shutdown + checkpoints them away; a naive copy of `app.db` alone while a server runs does + not. Use `sqlite3 app.db ".backup …"`. + - **WAL does not work on network filesystems** (NFS/SMB). Opt out with + `OS_DATABASE_SQLITE_JOURNAL_MODE=delete`, or per datasource with + `sqliteJournalMode: 'delete'` in the driver config (which outranks the env + var). Either form _applies_ `delete`, so it also converts a database that + already adopted WAL back — skipping would have stranded it. + + Nothing here fails a boot, and nothing is assumed: `PRAGMA journal_mode = X` + answers with the mode actually in force rather than raising on refusal, so the + reply is read back; and because a filesystem can accept WAL and then fail the + first read _through_ it, the mode is proven with a read and rolled back to + `delete` if that fails — with a warning naming the file and the escape hatch. + `synchronous` is untouched, so durability is exactly what it was. `:memory:` + databases are left alone, as is `auto_vacuum = INCREMENTAL`, which keeps + reclaiming under WAL (ADR-0057). + + `os db clean` now counts `-wal` / `-shm` as part of the database when it measures + what a `VACUUM` reclaimed, so bytes that were sitting in the log do not read as a + reclaim of zero. + + `@objectstack/driver-sqlite-wasm` deliberately stays out of WAL. Its live + database is in the WASM heap and what reaches disk is a byte image it exports, so + nothing reads the database across processes and the pragma buys it nothing — + while still being a persistent header change in the operator's file. sql.js + _accepts_ the pragma (its VFS is memory-backed), so this had to be declared + rather than discovered. + + It also now parks a `-wal` left behind by an unclean native-driver exit rather + than loading the image beside it: wasm SQLite cannot read that log, and leaving + it next to a freshly rewritten image would let a later real SQLite replay frames + that no longer belong to it. The warning names the file it parked and how to + recover what was in it. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [42e3b01] +- Updated dependencies [c8124e5] +- Updated dependencies [39eb01b] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [9e01213] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [3fe0ff1] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [c53aa53] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/driver-sql@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/plugins/driver-sqlite-wasm/package.json index cc69ea148e..ff0d138e34 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": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 bf73514489..c836e59dd5 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,208 @@ # @objectstack/embedder-openai +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index edca5beb20..2bac6583a5 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 e0fae322d2..62202c4fcb 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,216 @@ # @objectstack/knowledge-memory +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/service-knowledge@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 0618f12556..e792e773b6 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 c7a3ccc710..dee3ab38b8 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,216 @@ # @objectstack/knowledge-ragflow +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/service-knowledge@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 9399103b00..abb1af34fd 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 3c7abea223..c231bcf3e0 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,444 @@ # @objectstack/plugin-approvals +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 91f4c78: feat(approvals,spec): structured reassign hand-off parties on `sys_approval_action` (#4365) + + A reassign's audit row used to encode "who handed the slot to whom" only inside + a default free-text comment — `""`, two raw user ids — which + clients could neither parse reliably nor render readably, so the approvals + timeline showed opaque identifier soup for the single most important fact of + the entry. + + - `sys_approval_action` gains `reassign_from` / `reassign_to` + (`lookup('sys_user')`), written by `ApprovalService.reassign()`. + - `comment` is pure user input again: nothing is invented when the actor + supplies none. + - `listActions()` resolves both parties' display names into + `reassign_from_name` / `reassign_to_name`, alongside the existing + `actor_name`, so timelines can render "from A to B" without extra lookups. + - `ApprovalActionRow` (spec contract) declares the four new fields. + + Pre-existing rows keep their legacy comment; clients should prefer the + structured fields when present and fall back to `comment` otherwise. + +- cd6b9f2: `decisionOutputs` entries may now be declared `required` (objectui#2955). A typed entry `{ key, label?, type?, multiple?, required?: true }` tells the runtime — not just the decision UI — that an approver must supply the value: an **approve** carrying no value, or a blank one (`''`, whitespace, `[]`, an array of blanks), is rejected with `VALIDATION_FAILED` before any write, so the audit row and the request are untouched and the run can never resume past the node with the key missing. + + That gap is what the flag closes. `decisionOutputs` exists so a decision can route the next step (`approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }]`), but nothing made the approver actually answer: a skipped output resumed the run with the key absent, and the next node either faulted with `EXPRESSION_FAILED` or resolved an empty slate and stalled on `onEmptyApprovers: 'admin_rescue'` — long after the one person who could have filled it in had moved on. `onEmptyApprovers` was the only backstop, and it is a recovery mechanism, not a contract. + + **Reject never requires them.** The run leaves down the `reject` edge, where nothing reads the outputs — demanding routing data to say "no" would trap the rejection. Outputs still ride a reject when the approver filled them in. + + **No elevation bypass.** A one-click email action link and an `auto_approve` SLA escalation both fail the same way rather than advancing into a node that would resolve nobody; the escalation sweep already isolates a throwing request, so that decision stays pending and visibly overdue instead of silently breaking the run downstream. Enforcement is per decision, so on a `unanimous` / `quorum` node every approver supplies the required outputs and the finalizing decision's values are what the flow resumes with. + + `required` rides `normalizeDecisionOutputs`, so it reaches clients on `decision_output_defs` — a decision UI marks the field required and blocks locally instead of round-tripping to a 400. The console side ships in objectui#2955. + +### Patch Changes + +- 820eff9: fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786) + + `sys_approval_request.status` and `sys_approval_action.action` spelled their + option lists out — five values and twelve — each under a "Keep in sync with + `ApprovalStatus` / `ApprovalActionKind` (spec/contracts)" comment, while the + contract held the same sets as bare type unions. Seventeen strings matched by + hand across a package boundary, with nothing checking them. They did all still + agree; the sweep that found them (#3786) verified that verbatim before changing + anything. + + Agreeing is not the same as being held, and both directions of drift are quiet: + + - a value the **column** accepts and the contract omits is invisible to every + consumer typed against the contract — the row exists and nothing can narrow it; + - a value the **contract** declares and the column rejects surfaces only at write + time, on whichever tenant first reaches that transition. + + An audit vocabulary is a bad place for either. So the contract now publishes the + lists as values — `APPROVAL_STATUSES` and `APPROVAL_ACTION_KINDS` — with + `ApprovalStatus` / `ApprovalActionKind` derived from them via + `(typeof X)[number]`, and the two columns spread the constants. The per-entry + rationale (which action kinds move the flow, which are thread-only, why + `returned` differs from `recalled`) moved onto the constants, where the values + live. + + **New exports, no behaviour change.** The emitted option lists are byte-identical + — verified against the built artifact before and after. Existing imports of the + two types are unaffected; the types resolve to the same unions. + + `approval-vocabularies.test.ts` pins the qualifier that derivation alone cannot: + the columns agree with the contract _while the spread is there_, and the test + fails if either is re-inlined as a literal that has drifted. It also guards the + guard (an unresolvable import would compare two empty lists and pass) and asserts + the two vocabularies stay distinct, since a copy-paste pointing one column at the + other constant would satisfy "derived from the contract" while being the wrong + vocabulary entirely. + + Verified by mutation in both directions: adding a value to `APPROVAL_STATUSES` + propagates into the built `sys_approval_request.status` options (the derivation + is live, not a stale build), and re-inlining a drifted literal fails + `sys_approval_request.status offers exactly the contract statuses, in order`. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363) + + #4363 made a single paged read a partition of its result set. It could not make + a _walk_ one: seven background scans paged with a growing `offset` while writing + to the very rows they were reading, and an offset counts into a set those writes + are changing. Rows slide past the cursor and are never visited. + + That is not a slow page in any of these — it is a wrong answer wearing the shape + of a clean run: + + - **`rebuildApproverIndex`** built its desired state by walking + `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then + **deleted** every index row that state did not explain. A skipped request + meant an approver silently dropped from someone's queue. (The loop beside it + ordered by `created_at` — not unique, so its pages were never a partition + either.) + - **`verifyFileReferences`** decides which files nothing references. A record it + never visits is reported as an unreferenced file. + - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite + each row they read, so their own writes were shifting the set out from under + the cursor. Records were left unconverted and unsearchable by a run that + reported success. + - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and + it opens a migration gate on that evidence. + + All of them now go through `keysetWalk` (`@objectstack/types`): order by a + unique key, and seek past the last one instead of counting from the start. A + row's key does not move when the row is updated, and cannot be shifted when + another is deleted, so the walk is stable under exactly the mutation these + functions perform. It is also O(n) rather than O(n²/page) — measured on + Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek. + + One deliberate non-conversion: the REST **export** stream keeps its offset. It + honors a caller-chosen sort, and a keyset walk would have to re-order the export + by `id` to seek — changing what the user asked for to fix a cost. Its pages are + already a partition since #4363; only the depth cost remains. + + `keysetWalk` merges the cursor with `$and` rather than spreading it into the + caller's filter, so a walk whose own `where` constrains the key column + (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently + overwritten. When a `max` cap is set it reads one row beyond the cap to tell + "the cap stopped us" from "the source ended exactly there" — without that, a + walk that read everything still reports `truncated`, and a caller acting on it + goes looking for rows that were never withheld. + + The storage suites' fake engines now **throw** on an `offset` instead of serving + one, so the conversion is pinned rather than merely passing. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index b1d360e276..b5aeab6afa 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ce23c14a41..1457b3ca77 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/plugin-audit +## 17.0.0-rc.1 + +### Patch Changes + +- a946efd: fix(plugin-audit): the localized-summary tests stop charging a cold module load to one test's timeout (#4186) + + `audit-writers.test.ts` resolved `@objectstack/core` and its translation + bundle with `await import(...)` inside the first localized test's helper, so + that single test paid the whole cold-start cost — resolution plus vite + transform of a large barrel — while every later case ran warmed in ~1ms. + + That cost is real work being billed to a per-test timeout budget. The file + already carried a `{ timeout: 20_000 }` override for exactly this reason (its + comment measured the cold start at ~5s on a 4-vCPU runner). Under a full-repo + `pnpm test`, where a dozen packages' vitest workers compete, the cold start + grew past that bound too and the case failed at 20s — reproducibly in CI-like + load, never in isolation, which is the worst shape a red test can have: it + tracks machine load rather than code. + + Both imports are now static. The same work happens during collection, which no + single test's timeout is charged for, so the previously failing case runs in + 1ms and the timeout override is gone — the default timeout is now an honest + bound, and a case that exceeds it is a real hang rather than a slow import. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- a8dcc37: fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154) + + `MessagingService.emit()` writes `sys_notification` on every call — it is the + pipeline's single ingress (ADR-0030 L2). But the object was contributed to the + manifest by **`AuditPlugin`**, parked there with a comment saying it would stay + "until that [ADR-0030] migration lands". The migration landed; the parking did + not move. + + That left a real deployment hole, because `AuditPlugin` is an **optional** pair + in the CLI's plugin table. Install messaging without audit and nothing registers + the object, so the engine has no schema to issue DDL from and every `notify()` + fails with `no such table: sys_notification`. AuditPlugin never wrote the row + itself — it deliberately routes through this service's `emit()` ingress + (`getMessaging()` in `audit-writers.ts`), and its own exclusion list already + annotates the object as "messaging-owned (ADR-0030)". + + The contribution now lives with the writer, matching how every other + service-owned platform object is handled in this repo — `service-job` imports + `SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports + `SysImportJob`. Ownership of the _definition_ is unchanged: the object stays in + `@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`, because + owning a definition and contributing it to a running kernel are different + things. It is also added to the service's `provisionSystemTables`, so the table + is created with the rest of the pipeline it heads rather than lazily on the + first write. + + Found while migrating `notifications.hono.integration.test.ts` to in-memory + SQLite in #4065: that suite had to register the object itself to boot, which was + the deployment bug in miniature. The workaround is deleted in this change — the + suite now boots messaging alone and passes, which is the proof the product + declares what it writes. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 10c4a6d793..25aa225c5d 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 1bce5f3d36..1490b6a8b1 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,501 @@ # Changelog +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- bb1ce2e: fix(plugin-auth,plugin-webhooks): retire a dead degrade branch and an implicit transitive dependency (ADR-0116 follow-ups, #4187) + + Two concrete findings from the ADR-0116 consumer-side audit, plus the + authoring rule that would have prevented both. + + **`plugin-auth` claimed a fallback it did not have.** `init()` ran + `const dataEngine = ctx.getService('data'); if (!dataEngine) { warn('No data +engine service found - auth will use in-memory storage') }`. That branch could + never execute: `getService` **throws** for an unregistered service rather than + returning `undefined`, and this plugin declares a hard dependency on ObjectQL + (which registers `data` unconditionally), so a kernel without the engine fails + even earlier with `Dependency … not found`. The branch is removed and the real + contract is declared — `requiresServices: ['data', 'manifest']` — which also + replaces a trailing `// manifest service required` comment with the + machine-checked form of the same claim. `AuthManager` keeps its own optional + `dataEngine` guards: it is usable outside the plugin. + + **`plugin-webhook-outbox` was protected only transitively.** It resolves + `manifest` in `init()` with no fallback while depending on + `com.objectstack.service.messaging`, which in turn depends on ObjectQL, the + actual provider. That works today and would have broken silently the day + messaging stopped depending on the engine — surfacing as a crash inside an + unrelated plugin's init. It now declares `requiresServices: ['manifest']` + directly. + + Neither change alters ordering or boot outcomes on any current composition: + both plugins were already ordered correctly. What changes is what a broken + composition _says_, and that the guarantees are now checked rather than + inherited. + + Docs: `content/docs/plugins/anatomy.mdx` gains the three ADR-0116 fields and + the decision rule for resolving a service inside `init()` (hard dependency vs + `optionalDependencies` + `requiresServices`), including the two traps behind + these fixes — don't rely on a transitive provider, and don't write an + `if (!svc)` fallback after a bare `getService`. The api-registry example + declares the contract on all seven of its plugins instead of relying on + `kernel.use()` order. + +- ea24593: fix(plugin-auth): the auth catch-all yields paths better-auth does not own (#4088) + + `registerAuthRoutes` mounts `rawApp.all('${basePath}/*')` over the whole auth + namespace (`/api/v1/auth` by default), and that handler was **terminal**: it + returned better-auth's response unconditionally, including the 404 better-auth + produces for a path it does not implement. Any other plugin's route under that + prefix was therefore reachable only if it happened to register **first** — Hono + runs handlers matching a path in registration order and the first to return a + Response wins. + + That put a load-bearing surface at the mercy of `kernel.use()` order. + `@objectstack/plugin-hono-server` mounts `/auth/me/permissions` and + `/auth/me/localization` from its own `kernel:ready` hook; objectui's entire + permission layer reads the former and `core`'s auth gate allow-lists the latter + as an endpoint a gated user must still reach. Register `AuthPlugin` before + `HonoServerPlugin` and all of it silently 404s. + + A 404 from better-auth now means "this path is not mine" and the catch-all yields + to whatever else matched, in either registration order. Deliberately narrow: + + - **Only 404 falls through.** 401/403 are real better-auth answers, not + disclaimers of ownership. + - **Precedence still favours the namespace owner.** better-auth wins every path + it implements; only its leftovers are up for grabs. + - **The unclaimed-path wire shape is unchanged.** When nothing downstream + answers, better-auth's own 404 is returned verbatim rather than Hono's + `404 Not Found`. + + No configuration changes and no new routes. The only behavioural difference for + an existing deployment is that a route another plugin mounts under + `/api/v1/auth/*` now answers regardless of plugin order — previously it answered + only in the lucky order. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- c54c822: fix(spec,plugins): sweep the auth/session slot lookups — 31 sites typed, and the user-import metadata reader was pointed at a service that never had the method (#4251) + + Batch B2 of the #4251 sweep: every service-lookup erasure in the auth/session + family. `plugin-auth/auth-plugin.ts` (20), `plugin-hono-server/current-user-endpoints.ts` + (10) and `plugin-security/security-plugin.ts` (1) now pass the slot's contract + type; the ratchet baseline drops **171 → 140 sites, 40 → 37 files**. + + **The yield.** `POST /admin/import-users` resolved the `metadata` slot and probed + `metadataService?.getMetaItem` to decide whether to pass the import's field-coercion + dependency. `getMetaItem` is a **protocol** method — `ObjectStackProtocolImplementation`, + registered by MetadataProtocolPlugin under the `protocol` slot. `MetadataManager`, + which occupies `metadata`, has never had it. So the probe was false on every + deployment and the dep was never passed: imported rows reached `sys_user` + uncoerced, with the branch that says otherwise sitting right there. This is the + same shape as #4127's dead `automation.trigger` and #4321's `registerInMemory` + probes — a capability the code advertises and the runtime cannot deliver, kept + invisible by the `any`. Typing the lookup to `IMetadataService` is what turned it + into a compile error. The route reads `protocol` now. + + `/me/apps` reached ObjectQL's **private** `_registry` through `as any` while + `/auth/me/permissions`, two handlers up in the same file, read the public + `registry` getter over the same field of the same object. Both read the public + accessor now; the one test that stubbed `_registry` was pinning the private reach + and stubs `registry` instead. + + **Contract, from evidence.** `IDataEngine`'s read methods (`find` / `findOne` / + `count` / `aggregate`) declare the trailing `options?: BaseEngineOptions` + argument they have always accepted. ObjectQL's own doc explains why it exists: + reads once took their context inside the query while writes took it in trailing + `options.context`, so the same `{ context }` object was correct as `insert`'s 3rd + argument and **silently dropped** as `find`'s — "an intended `isSystem` bypass + just vanished". The engine accepts both channels; the contract exposed only the + query one, so callers using the trailing channel — the current-user endpoints' + permission-set loader among them — could only reach it by erasing the lookup. + Adding an optional trailing parameter breaks no implementor (the existing + minimal-implementation test proves it) and no caller. `BaseEngineOptions` was + already exported, sitting unused under the "legacy/deprecated" heading, which is + why the contract went looking and did not find it; it moves up beside the other + QueryAST-aligned types with the rationale attached. One new spec test pins the + trailing argument at the call site — the position where the old contract rejected it. + + **Where the contract does not reach, the escape hatch is named.** Three slots + resist a spec type today and each gets a narrow, documented local interface + instead of `any`: `security.permissions` (plugin-security's `PermissionEvaluator` + — plugin-hono-server must not depend on an optional plugin), `settings` + (service-settings' resolver, same reason), and ObjectQL beyond `IDataEngine` + (`registry` / `getSchema` / `registerHook` / `registerMiddleware`). That last one + is deliberate scope: the standing record on `getObjectQL` in `@objectstack/runtime` + says ObjectQL is genuinely wider than `IDataEngine` and nobody has written the + wider contract, so typing the whole thing `IDataEngine` would be "the more + comfortable-looking lie". These declarations are what that contract gets written + from, and what it deletes. + + No behavior changes beyond the two fixes above. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [fccec22] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [fae74b5] +- Updated dependencies [366105c] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [f0d6594] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [7309c81] +- Updated dependencies [495019b] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [be7945a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/rest@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index ac424e37e8..e7c304e2be 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 95f79822d9..f5d29d004e 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,573 @@ # @objectstack/plugin-dev +## 17.0.0-rc.1 + +### Minor Changes + +- 4dc14cc: Retire the three `security.*` dev stubs, and refuse to load `plugin-dev` under `NODE_ENV=production` (#4093). + + **The security stubs are gone.** When `@objectstack/plugin-security` was not installed, `plugin-dev` filled its three slots with fakes that inverted the decision each stood in for: `security.permissions.checkObjectPermission()` returned `true` for everything, `security.rls.compileFilter()` returned `null` so no row-level predicate was applied, and `security.fieldMasker.maskResults()` returned rows unmasked. ADR-0076 D12's rule — learned from the analytics shim it retired in #3891 — is that a fallback may degrade features, **never security semantics**; `packages/spec/src/contracts/security-service.ts` says the same from the other side (these three are plugin-security's internals, and access-narrowing answers must fail CLOSED). Since `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, the package merely being absent was enough to swap real RBAC/RLS/masking for allow-all behind a single `warn` line. + + The slots now stay empty — which is what production has without SecurityPlugin, and what every consumer already handles — and the boot log states plainly that RBAC, row-level security and field masking are not being enforced. + + **`plugin-dev` now refuses to initialize under `NODE_ENV=production`.** It is a published package that registers development fakes for every unclaimed core service slot, including ones that report success for work they never did, and it had no environment check of its own: an `objectstack.config.ts` carrying `new DevPlugin()` into a production deploy got the whole fake slate with only a boot log to say so. `init()` now throws there. Set `OS_ALLOW_DEV_PLUGIN=1` if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable). + + FROM → TO: a stack that relied on the dev security stubs was not being protected by them — it was being told everything was allowed. Install `@objectstack/plugin-security` to enforce RBAC/RLS/masking, or accept the empty slots (unchanged behaviour on every path that already handled an absent SecurityPlugin). A production process that loaded `plugin-dev` must now either drop it and install the real services, or opt in explicitly with `OS_ALLOW_DEV_PLUGIN=1`. + + Also: `plugin-hono-server`'s `/auth/me/permissions` resolves `security.permissions` and `metadata` through the same guarded lookup its three sibling lookups already used. An unregistered slot makes `getService` throw, which previously landed in the outer catch — the same fail-open response body, but logged as "/auth/me/permissions failed" on every console navigation instead of taking the deliberate `!evaluator` branch. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- d0d7464: feat(plugin-dev)!: the stub table is retired — DevPlugin assembles real plugins and registers no service implementations of its own (ADR-0115, #4093, #4104). + + DevPlugin used to fill every core-service slot no real plugin occupied with a dev stub. Every one of those stubs is gone. A slot nothing fills now stays EMPTY, exactly as in production: routes answer 404/501, discovery reports `unavailable`, and in-process consumers must handle absence — which production already required of them. FROM → TO per retired slot: + + | Slot | The stub did | Instead | + | :--------------------------------- | :---------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `security.permissions` | allow-all `checkObjectPermission()` | install `@objectstack/plugin-security` (already part of the default assembly) | + | `security.rls` | compiled no row filter | same — `plugin-security` | + | `security.fieldMasker` | returned results unmasked | same — `plugin-security` | + | `auth` | `verify()` accepted everyone as admin | install `@objectstack/plugin-auth` (already part of the default assembly) | + | `data` | accepted writes, stored nothing | install `@objectstack/objectql` (already part of the default assembly) | + | `ui` | shapeless `{}` placeholder | nothing consumed it; handle the absent slot | + | `ai` | placeholder chat/complete answers | install a real AI service | + | `automation` | `execute()` reported success without running | install an automation engine plugin | + | `notification` | claimed "sent", delivered nothing | install a notification service | + | `file-storage` | in-memory files lost on restart | `@objectstack/service-storage` — now auto-wired by DevPlugin when installed (local-disk adapter) | + | `realtime` | in-process pub/sub copy | `@objectstack/service-realtime` — now auto-wired by DevPlugin when installed (its default in-memory adapter) | + | `search` | in-memory substring index | no consumer resolves this slot; a future search service ships its own dev strategy | + | `workflow` | unvalidated state transitions | no consumer resolves this slot; a future workflow service ships its own dev strategy | + | `metadata` | a second hand-written copy of core's `createMemoryMetadata` | no behavior change — the kernel pre-injects core's fallback for empty core slots (`CORE_FALLBACK_FACTORIES`), and ObjectQL registers the real metadata service in the default assembly | + | `cache` / `queue` / `job` / `i18n` | re-registered core's `createMemory*` fallbacks | no behavior change — the kernel pre-injects the same core fallbacks automatically; install `@objectstack/service-cache` / `service-queue` / `service-job` for real engines, and i18n auto-wires from the stack's translations (unchanged) | + + Also new, from the same ADR: + + - **Production guard** (first shipped with the security-trio subset): `DevPlugin.init()` throws when `NODE_ENV === 'production'` — the assembly is built around a well-known default auth secret and a seeded dev admin. Escape hatch: `OS_ALLOW_DEV_PLUGIN=1`. + - **Assembly auto-wire**: `@objectstack/service-storage` and `@objectstack/service-realtime` are wired as optional child plugins when installed (both ship with DevPlugin's dependencies), so dev keeps working file storage and realtime through real implementations. + - `options.services` keys for the retired stubs are accepted and ignored; `'file-storage'` / `'realtime'` now toggle the real service wiring. + + One-line fix for an upgrading stack: if something you called in dev now throws "service not found" or 404s, that call was consuming a fabricated answer — install the real service for that slot (table above), or make the caller tolerate absence the way it already must in production. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 2a37694: fix(plugin-dev,types): the production escape hatch stops being silent (#3900) + + `DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and + `OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned + early with **no output at all**: the process ran the development assembly while + every log line and the ready banner read like an ordinary production start. + + That reproduces, one level up, the defect the guard exists to close. The guard's + own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded _and brands + it everywhere an operator looks_, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s + contract is "logged loudly at startup". An escape hatch that says nothing leaves + the operator's only evidence of a degraded state in an env var they may not have + set themselves. + + **The override now brands itself, twice.** A warning at `init()` — emitted + before any assembly work, so it survives an assembly step that later throws — + and a repeat on the ready banner, which is the surface an operator actually + reads: + + ``` + ⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot + guard was explicitly overridden. This process is running the DEVELOPMENT + assembly, which is not hardened for production traffic (ADR-0115 D6). + • Auth secret is the default published inside @objectstack/plugin-dev. It is + public, so anyone can mint a session this stack accepts. Pass `authSecret` + explicitly. + • Data goes to the in-memory driver with persistence disabled — every record + is lost when this process exits. + ``` + + Only hazards that are live for _that_ configuration are named: the secret line + is suppressed when the operator passed their own `authSecret`, and the driver + line when the `driver` toggle is off. The dev-admin seed is deliberately absent + — `plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to + `NODE_ENV === 'development'` and cannot fire on this path, so warning about it + would spend the attention the real hazards need. + + **New export — `resolveAllowDevPlugin()` (`@objectstack/types`).** The flag moves + off a bare `process.env['OS_ALLOW_DEV_PLUGIN'] === '1'` and joins the + `OS_ALLOW_*` family's shared truthy vocabulary, next to + `resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`. + + FROM → TO for operators: `OS_ALLOW_DEV_PLUGIN=1` keeps working unchanged. + `OS_ALLOW_DEV_PLUGIN=true` (and `on` / `yes`, case-insensitive, surrounding + whitespace ignored) **now takes effect** where the strict comparison previously + ignored it and failed the boot. That is a widening, in the direction an operator + setting the flag already intended; falsy and unrecognised values still refuse to + boot, and unset still means "fail fast". If you were relying on + `OS_ALLOW_DEV_PLUGIN=true` being inert as a way to keep the guard armed, unset + the variable instead. + + No change to the refusal path, which this issue re-verified end to end: + `kernel.use()` only registers, `initPluginWithTimeout` does not catch, + `bootstrap()` rethrows, and `os serve`'s outer handler prints the message and + exits `1`. The `throw` is genuinely fatal here, so it needs none of the + `process.exit(1)` the tenancy guard required for sitting inside a broad `catch`. + +- 45dc446: Every in-memory fallback and dev stub now self-describes with the standard `__serviceInfo` descriptor, classified by what it actually is (#4058 step 1). + + ADR-0076 D12 gave services one way to say "I am not the real thing", but the producers never converged on it: + + - The kernel's own fallbacks (`createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`) carried `_fallback: true` — a marker **no** consumer recognized, `readServiceSelfInfo` included — so both discovery builders reported them as fully `available`. + - `plugin-dev` marked all of its implementations with the same `_dev: true`, normalized to `status: 'stub', handlerReady: false`. That declared a working in-memory search index exactly as fake as an AI stub returning invented text. + + Both now carry `__serviceInfo`, split by a rule that holds across the whole set: + + - **`degraded`** — really does the work, with reduced capability: `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `metadata`, `workflow`, `realtime`. Its answers are true answers; the `message` names what is missing (no persistence, no scheduling timer, no state-machine validation, …). + - **`stub`** — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. Never to be mistaken for a capability. + + `handlerReady: false` is set independently wherever no HTTP handler serves the slot (`cache` / `queue` / `job` / `realtime`, and every `stub`). + + Discovery output changes accordingly — a kernel fallback that used to report `status: 'available'` now reports `degraded` with an explanatory message. No routing, gating, or dispatch behavior changes: every dispatcher domain still resolves services exactly as before. Consumers reading `discovery.services.*` get the truth instead of a uniform claim. + + For anything that duck-typed the old markers: `svc._fallback` / `svc._dev` → `readServiceSelfInfo(svc)` from `@objectstack/spec/api` (the legacy `_dev` key is still understood by that reader, so third-party stubs carrying it keep working). + +- 7309c81: fix(driver-memory,spec): persistence is opt-in again — `new InMemoryDriver()` is pure in-memory (#4065) + + `InMemoryDriverConfig.persistence` defaulted to `'auto'`, and in Node.js `'auto'` + means **file**. So a bare `new InMemoryDriver()` — the shape every caller in this + repo used — silently wrote `.objectstack/data/memory-driver.json` into the process + CWD and reloaded it on the next boot. The default is now `false`. + + **This restores the accepted design rather than replacing it.** #815, the issue + that introduced the persistence capability, specified it as opt-in in requirement + \#1 — "默认情况下不启用持久化(纯内存,行为不变)" — and listed + `new InMemoryDriver()` under "纯内存" in its own config examples. The `'auto'` + default was a drift from that spec. + + What let the drift survive is worth naming, because it is not "there was no + test". `MemoryConfigSchema` _did_ pin the default, and asserted `'auto'`; the + driver honoured `'auto'`; so spec and implementation agreed, and the pair looked + verified. What nothing checked was whether the value they agreed on was the one + #815 accepted. The driver's own `persistence.test.ts` could not have caught it + either — every case there passes `persistence` explicitly, so the omitted-value + path was untested on the implementation side. Both sides are now covered: three + behavioural tests in `persistence.test.ts` (no CWD write, no cross-instance row + carry-over, opt-in still persists) and the flipped schema assertion. + + **The symptom this fixes.** `packages/runtime/src/datasource-autoconnect.test.ts` + seeds two rows with fixed ids and asserts the exact set. Run 1 passed and wrote + the rows to disk; run 2 loaded them back, appended two more, and failed with four + rows; run N had 2N. CI never saw it — every job is a fresh clone, so every CI run + is run 1 — but `pnpm test` twice in one working tree could only ever go green + once. The persisted file's `created_at` values, one pair per run, were the proof. + + (#4083 fixed that particular suite from the factory side, and its regression + test is kept as-is. The blast radius was wider than one suite, though: **every** + bare `new InMemoryDriver()` inherited the default, so any code path constructing + one directly wrote to its working directory. Unit tests should not have write + side effects on the CWD at all.) + + **Migrating.** Callers that want durability now ask for it: + + ```ts + new InMemoryDriver(); // pure in-memory (new default) + new InMemoryDriver({ persistence: "file" }); // Node.js, durable across restarts + new InMemoryDriver({ persistence: "local" }); // browser, durable across reloads + new InMemoryDriver({ persistence: "auto" }); // previous default behaviour + ``` + + The `'auto'` / `'file'` / `'local'` / custom-adapter paths are unchanged; only + the value used when `persistence` is omitted moved. + + **Relationship to #4083.** That issue fixed the same hazard one consumer at a + time, and landed first: `createDefaultDatasourceDriverFactory` now passes + `persistence: false` for a declared `{ driver: 'memory' }` datasource and scopes + an opted-in destination _per datasource_, and the dev sqlite step-down's + last-resort rung passes `false` too. Both are kept exactly as #4083 wrote them. + This change closes the half they deliberately left open — a directly-constructed + `new InMemoryDriver()` — which is the path that still wrote into the working + directory of whatever process happened to build one. + + The two are complementary, not redundant. #4083's per-datasource scoping is + still the only thing that expands `'auto'`/`'file'`/`'local'` into a destination + carrying the datasource name, so two pools that DO opt in never alias one file; + its explicit `false` becomes belt-and-braces, which is the right posture for a + path that must never persist. + + `DevPlugin`'s driver is now explicitly `persistence: false`, matching the cache, + queue, job, i18n, storage and search stubs it ships beside — it was the one piece + of that stack that quietly outlived the process. + + **One claim trimmed, no behaviour attached.** The class docstring called this a + "production-ready implementation of the ObjectStack Driver Protocol". It stores + no constraints at all — `create()` is a `table.push()` and `syncSchema()` only + allocates an array — so there is no primary key, uniqueness, `NOT NULL`, foreign + key or column typing, and `bulkCreate` lands duplicate ids where a SQL driver + raises a violation (the second finding in #4065). The docstring now says so, and + points test authors at in-memory SQLite. Per Prime Directive #10 the fix for + `declared ≠ enforced` is to implement it, trim the claim, or file it; with this + driver moving to maintenance-only the claim is what goes. + +- a3cb9c8: Retire the dev-mode `analytics` stub, and make the dispatcher gate `/analytics` on `handlerReady` rather than on service presence (#4000). + + Retiring the degraded analytics shim (#3891) made an empty `analytics` slot the honest signal: `/api/v1/analytics/*` 404s and discovery reports `unavailable`. `plugin-dev` refilled that slot with a stub, which re-created the retired shape in dev mode — the dispatcher gated on "is a service registered", so the stub was called like a real engine and its empty result came back as a 200. + + - `plugin-dev` no longer registers an `analytics` dev stub; the slot stays empty (`NO_DEV_STUB_SERVICES`). Every other dev stub is unchanged. + - The `/analytics` domain, its route-mount gate, and discovery's `routes`/`features` now share one predicate (`isAnalyticsServiceServeable`): a service that self-declares `handlerReady: false` (ADR-0076 D12 — `__serviceInfo`, or plugin-dev's legacy `_dev: true`) is treated as an empty slot. A `degraded` implementation that genuinely serves requests keeps serving; `discovery.services.analytics` still reports a registered stub as `status: 'stub'`, which says more than `unavailable` would. + + FROM → TO for dev setups that relied on the stub answering `POST /api/v1/analytics/query` with `{ rows: [], fields: [] }`: install the real engine — `@objectstack/service-analytics` runs an InMemory strategy and needs no database of its own. Nothing else changes; hosts that already install it (including `os serve`, where `analytics` is in `ALWAYS_ON_CAPABILITIES`) are unaffected. + +- 4be9d99: fix(runtime,hono,plugin-dev): retire the dispatcher's `/storage` bridge — it never spoke the storage contract (#4087) + + `POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` were a + dispatcher-side bridge to the `file-storage` service slot, written against a + service shape that does not exist: + + - **Upload** called the contract's `upload(key, data, options?)` as + `upload(file, { request })` — the parsed file object landed in the `key` + slot and `{ request }` in `data`. That is a `TypeError` against every + implementation in the repo (`S3StorageAdapter`, `LocalStorageAdapter`, + `SwappableStorageService`, plugin-dev's in-memory one), not a + near-miss: `Buffer.from({}) → ERR_INVALID_ARG_TYPE`, or an object used as + an S3 object key / `path.join` segment. + - **Download** branched on `result.url` / `result.redirect` / `result.stream` + / `result.mimeType` while the contract's `download(key)` resolves a + `Buffer`, so every branch fell through and the route answered a + JSON-serialized Buffer. + + Both routes are removed, along with `HttpDispatcher.handleStorage()`, the + `/storage` domain registration, the dispatcher-plugin mounts and the two route + ledger rows. + + **Migration.** There is nothing to migrate off in practice — neither route + could complete a request. (They were reachable: `service-storage` mounts + `/storage/upload/presigned`, not `/storage/upload`, so nothing shadowed them. + They simply had no caller — no SDK method builds those URLs.) + `/api/v1/storage` is `@objectstack/service-storage`'s surface and always was + the working one: + + - Upload — FROM `POST /api/v1/storage/upload` TO the presigned protocol + (`POST /storage/upload/presigned` → direct `PUT` to the returned URL → + `POST /storage/upload/complete`), or `client.storage.upload(file)`, which + runs all three steps. + - Download — FROM `GET /api/v1/storage/file/:id` TO + `GET /storage/files/:fileId/url` (`client.storage.getDownloadUrl(fileId)`) + for a signed URL, or `GET /storage/files/:fileId` for a stable browser URL + that 302s to it. + + Install `@objectstack/service-storage` to get those routes; without it + `/api/v1/storage` now has no handler, which is the same answer every other + uninstalled capability gives. + + Two follow-on corrections keep `declared === enforced`: + + - `@objectstack/hono` no longer mounts `app.all('/storage/*')`. That + wildcard claimed the whole `/storage` subtree for the two dead routes, so + every other path under it — service-storage's protocol above all — got the + bridge's own 404 rather than falling through. Storage is ordinary catch-all + traffic now. + - Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared + `handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory + implementation now self-declares `handlerReady: false`. #4058 deliberately + left that one serving because the `/storage` bridge was still there to serve + it; with the bridge retired nothing routes HTTP to that slot, so `false` is + the honest value — the position `realtime` has held since ADR-0076 D12. The + implementation keeps working for in-process callers; it is simply no longer + advertised as a reachable HTTP capability. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [3ec8186] +- Updated dependencies [698cbc2] +- Updated dependencies [b1863a5] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [bb1ce2e] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [0f12193] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [ea24593] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [fccec22] +- Updated dependencies [2af1988] +- Updated dependencies [b3a2318] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [fae74b5] +- Updated dependencies [545d931] +- Updated dependencies [a225ef5] +- Updated dependencies [366105c] +- Updated dependencies [c9d254a] +- Updated dependencies [c8124e5] +- Updated dependencies [9e8f04d] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [4dc14cc] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [f0d6594] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [d4720ca] +- Updated dependencies [43ff598] +- Updated dependencies [e5a4d26] +- Updated dependencies [839982e] +- Updated dependencies [623e555] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [7309c81] +- Updated dependencies [495019b] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [be7945a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [efcd68c] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [68dea0b] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/service-storage@17.0.0-rc.1 + - @objectstack/driver-memory@17.0.0-rc.1 + - @objectstack/plugin-security@17.0.0-rc.1 + - @objectstack/rest@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/plugin-auth@17.0.0-rc.1 + - @objectstack/account@17.0.0-rc.1 + - @objectstack/plugin-hono-server@17.0.0-rc.1 + - @objectstack/service-i18n@17.0.0-rc.1 + - @objectstack/service-realtime@17.0.0-rc.1 + - @objectstack/setup@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 27843c085c..e3d982a7e6 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index c9babe2cdb..1b1ac0f785 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,250 @@ # @objectstack/plugin-email +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 74f6c13dc6..79d6937a9c 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 3071d58bea..9b8ae1f74c 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,748 @@ # @objectstack/plugin-hono-server +## 17.0.0-rc.1 + +### Major Changes + +- e5a4d26: feat(plugin-hono-server)!: delete the CRUD/discovery convenience surface and the `registerStandardEndpoints` flag — the plugin is a transport adapter (#4073) + + Completes the retirement. `HonoServerPlugin` now owns the socket, the middleware + and the three current-user endpoints, and nothing else. The data and discovery + APIs have one owner each: `@objectstack/rest` and the runtime dispatcher + (ADR-0076 D11). + + **Removed** + + - `POST/GET /api/v1/data/:object` and `GET /api/v1/data/:object/:id` — the raw + C+R surface that delegated straight to ObjectQL. + - `GET /api/v1/discovery` and `GET /.well-known/objectstack` — this plugin's + third discovery payload, which predated `DiscoverySchema` and could not + satisfy it (no `services`, the ADR-0076 D12 source of truth). + - The `registerStandardEndpoints` option. It is gone, not defaulted off: passing + it is now a type error, and passing it via `as never` mounts nothing. + + **Unaffected** + + - `/auth/me/permissions`, `/auth/me/localization` and `/me/apps` — this plugin + is the platform's only supply and they register unconditionally (#4144). + - Every composed host: `os serve`, `objectstack dev`, cloud's objectos and every + documented composition mount REST and/or the dispatcher, which already served + these routes and answered byte-identically with the flag on or off (#4260). + + **Migration** — only a host that mounts `HonoServerPlugin` with neither owner is + affected. It now has no data or discovery API, and the boot warns once naming + both remedies. Mount `createRestApiPlugin` from `@objectstack/rest` for full + CRUD behind the gate stack, or `createDispatcherPlugin` from + `@objectstack/runtime`. There is no flag to opt back in. + + **Why** — the surface was duplicate and lesser supply (C+R only, a subset of the + gates, a non-conforming discovery payload), and it charged rent: #2567, #3298 + and #4018 each had to re-implement a platform invariant on it after the fact, + because a second implementation of a route is a second place every future + invariant must be remembered. + +### Minor Changes + +- 545d931: fix(plugin-hono-server): the current-user endpoints answer from the kernel that OWNS the request (cloud#927) + + `/api/v1/auth/me/permissions`, `/auth/me/localization` and `/me/apps` resolved + their answer from the service locator captured at REGISTRATION time. On a + single-environment host that is the only kernel, so it is right. On a + **multi-tenant** host it is the routing shell — and identity is not there. + cloud's `ArtifactKernelFactory` mounts `AuthPlugin` per environment, and its host + kernel deliberately has none ("AuthPlugin is intentionally NOT injected on the + host"), so `getService('auth')` threw, the session resolver fell to its catch, and + every authenticated tenant caller got `{authenticated:false}`. + + That is worse than an error: objectui's `MePermissionsProvider` reads + `authenticated:false` as ANONYMOUS and keeps its permissive default + (`return data.authenticated !== true`), because a guest surface has no resolvable + permissions by design. So the console's FLS / `apiOperations` hints were + systematically wrong — not a bypass (the server still enforces per request), but + exactly the client/server divergence `foldWildcardSuperUser` and + `clampManagedObjectWrites` exist to close, one layer up. + + These endpoints now consult the host's ADR-0006 **`kernel-resolver`** seam per + request — the same seam the runtime dispatcher has used since Phase 5, so + multi-tenant routing has one strategy rather than two: + + - **No `kernel-resolver` registered** → unchanged. Single-environment hosts, + `os serve`, and the QA conformance host see no difference. + - **A kernel** → that kernel's `auth` / `objectql` / `metadata` / + `security.permissions` answer. + - **`undefined`** → the registration-time locator, which is the seam's contract + for an unscoped / control-plane request. + - **A throw** → no answer at all: the thrown status when it carries one (cloud's + `KernelWarmingError` is 503 + `Retry-After`), else 503 + `environment_unavailable`. Falling back to the default kernel would hand back a + confidently-wrong `{authenticated:false}` that the client fails OPEN on. + + The seam is read **lazily, per request**, never captured at registration — a host + may register these routes before `kernel.bootstrap()` (to outrank an + `/api/v1/auth/*` wildcard), which is before the plugin that registers the + resolver has run its `init()`. + + **FROM → TO for host adapters.** `CurrentUserEndpointsContext` gains an optional + `getKernel(): unknown`, the `defaultKernel` argument the seam takes. A + `PluginContext` already satisfies it, so hosts that mount `HonoServerPlugin` need + no change. A host passing a hand-rolled locator to + `registerCurrentUserEndpoints` should add it: + + ```diff + registerCurrentUserEndpoints({ + rawApp: httpServer.getRawApp(), + - ctx: { getService: (n) => kernel.getService(n) }, + + ctx: { getService: (n) => kernel.getService(n), getKernel: () => kernel }, + }); + ``` + + Without it a multi-tenant host cannot be asked which kernel owns the request and + keeps the old provenance — a silent downgrade, so it is worth adding even where + the host is single-environment today. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- d4720ca: feat(plugin-hono-server): export `registerCurrentUserEndpoints` so a host without the plugin can still supply them (cloud#924) + + `GET /api/v1/auth/me/permissions`, `/api/v1/auth/me/localization` and + `/api/v1/me/apps` are the platform's **sole** supply — neither + `@objectstack/rest` nor `@objectstack/runtime` registers any `/me/*` route, the + objectui console reads the first for its whole permission layer and the second + for regional defaults, and `core`'s auth gate allow-lists the last two as + endpoints a gated user MUST still reach. #4073/#4079 freed them from the + `registerStandardEndpoints` flag, but left the supply welded to + `HonoServerPlugin`: a host that stands up a bare `HonoHttpServer` and registers + it as `http.server` itself — rather than mounting the plugin — got no provider at + all, and the console's FLS / `apiOperations` had no server-side answer on that + startup path. + + Registration needs a Hono app and a service locator, not ownership of the + listening socket, so it is now a standalone module (`./current-user-endpoints`) + that both shapes call: + + ```ts + import { registerCurrentUserEndpoints } from "@objectstack/plugin-hono-server"; + + const httpServer = new HonoHttpServer(); + kernel.registerService("http.server", httpServer); + registerCurrentUserEndpoints({ + rawApp: httpServer.getRawApp(), + // any { getService, logger } — a PluginContext satisfies it structurally + ctx: { + getService: (n) => { + try { + return kernel.getService(n); + } catch { + return undefined; + } + }, + }, + }); + ``` + + It is **idempotent**: it returns `false` and registers nothing when all three + paths are already served, so a host may both call it eagerly on the raw app AND + mount the plugin — the plugin's `kernel:ready` registration then no-ops instead + of shadowing the host's routes with dead duplicates. Registering early matters, + because Hono's only route precedence is first-registration-wins and plugin-auth + mounts a `/api/v1/auth/*` wildcard that `/auth/me/*` must outrank. + + **No behaviour change for existing hosts.** `os serve` and every host that mounts + `HonoServerPlugin` register the same three routes, in the same `kernel:ready` + position, with the same response shapes — the plugin now delegates to the shared + registrar instead of owning a private method. + + **Moved exports (same package, same names, no rename).** `foldWildcardSuperUser`, + `clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, + `annotateEffectiveApiOperations`, `ManagedSchemaLike` and `ApiExposureSchemaLike` + now live in `./current-user-endpoints` alongside the endpoint they shape. Importing + them from the package root (`@objectstack/plugin-hono-server`) is unchanged; only a + deep import of `.../dist/hono-plugin` would need updating, and the package exposes + no such subpath. + +- 43ff598: fix(plugin-hono-server): stop gating the current-user endpoints behind `registerStandardEndpoints` (#4073) + + `registerStandardEndpoints` gated two unrelated things behind one flag: + + - **Duplicate supply** — raw `POST/GET /api/v1/data/:object` (create + read + only), which `@objectstack/rest` also serves and, registering first, is what + actually answers; plus `GET /api/v1/discovery` and + `/.well-known/objectstack`, which the dispatcher and REST own and which this + surface already cedes to them (#4018). + - **Sole supply** — `GET /api/v1/auth/me/permissions`, + `/api/v1/auth/me/localization` and `/api/v1/me/apps`. Nothing else in the + platform mounts these: neither `@objectstack/rest` nor `@objectstack/runtime` + registers any `/me/*` route, the console's entire permission layer reads + `/auth/me/permissions`, the console reads `/auth/me/localization` for regional + defaults, and `core`'s auth gate allow-lists `/me/apps` + `/me/localization` + as endpoints a gated user MUST still reach to bootstrap the remediation UI. + + `os serve` gets all of it only because the flag defaults to `true` — the CLI + constructs `new HonoServerPlugin({ port })`. So `registerStandardEndpoints: +false`, whose documented job is the optional CRUD/discovery convenience surface, + silently took the console's permissions and localization down with it. + + The three current-user endpoints now register **unconditionally**, and the flag + covers the duplicate half only — what its name and docs always claimed. + + **FROM → TO.** If you set `registerStandardEndpoints: false` and worked around + the missing endpoints (proxying `/auth/me/permissions` yourself, or pinning the + flag to `true` purely to keep them), you can drop that workaround: the endpoints + are now present either way. No route is removed and no response shape changes, + so a host that left the flag at its default sees no difference. If you relied on + `false` meaning "this plugin mounts no `/api/v1` routes at all", that is no + longer true — it never was for `os serve`, which is the only host that shipped + the flag's default. + + Also removes three unreferenced `*_ENDPOINT_PRIORITY` constants; + `DISCOVERY_ENDPOINT_PRIORITY = 900` in particular implied a route-priority + mechanism that does not exist (precedence here is Hono's + first-registration-wins). + +- 623e555: feat(plugin-hono-server): `registerStandardEndpoints` now defaults to `false` — the deprecated CRUD/discovery convenience surface is opt-in (#4073) + + The flag mounts raw C+R `/api/v1/data/:object` and `/api/v1/discovery` / + `/.well-known/objectstack`. Every path it mounts is duplicate — and lesser — + supply: C+R only, a subset of the gates, a pre-`DiscoverySchema` discovery + payload. `@objectstack/rest` serves full `/data` CRUD behind the whole gate + stack, REST/the dispatcher own discovery (#4018 cede), and #4260 pinned that a + composed host answers **byte-identically** with the flag on or off. The surface + has also been a standing tax: #2567, #3298 and #4018 each had to re-implement a + platform invariant here after the fact. + + **FROM → TO** + + - **Composed hosts (REST and/or the dispatcher mounted)** — `os serve`, + `objectstack dev`, cloud's objectos, every documented path: **no change**. + Those plugins already answer every route this surface covered, and answered + them first. + - **Bare hosts (HonoServerPlugin only)**: `/api/v1/data/:object`, + `/api/v1/discovery` and `/.well-known/objectstack` are **no longer mounted by + default**. The boot now logs a warn naming the flag and the remedy instead of + leaving a silent 404. Migrate by mounting `createRestApiPlugin` from + `@objectstack/rest` — it needs the same `objectql` service this surface + already required, and returns full CRUD plus the gate stack — or pass + `registerStandardEndpoints: true` to keep the legacy surface during the + deprecation window. + - The current-user endpoints (`/auth/me/permissions`, `/auth/me/localization`, + `/me/apps`) are **unaffected** — they never sat behind this flag (#4144) and + register unconditionally. + + The flag is now marked `@deprecated`. Next step per #4073: one release of + observation, then `registerDiscoveryAndCrudEndpoints` (and the flag) are deleted + and this plugin becomes a pure transport adapter (ADR-0076 D11). + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 4dc14cc: Retire the three `security.*` dev stubs, and refuse to load `plugin-dev` under `NODE_ENV=production` (#4093). + + **The security stubs are gone.** When `@objectstack/plugin-security` was not installed, `plugin-dev` filled its three slots with fakes that inverted the decision each stood in for: `security.permissions.checkObjectPermission()` returned `true` for everything, `security.rls.compileFilter()` returned `null` so no row-level predicate was applied, and `security.fieldMasker.maskResults()` returned rows unmasked. ADR-0076 D12's rule — learned from the analytics shim it retired in #3891 — is that a fallback may degrade features, **never security semantics**; `packages/spec/src/contracts/security-service.ts` says the same from the other side (these three are plugin-security's internals, and access-narrowing answers must fail CLOSED). Since `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, the package merely being absent was enough to swap real RBAC/RLS/masking for allow-all behind a single `warn` line. + + The slots now stay empty — which is what production has without SecurityPlugin, and what every consumer already handles — and the boot log states plainly that RBAC, row-level security and field masking are not being enforced. + + **`plugin-dev` now refuses to initialize under `NODE_ENV=production`.** It is a published package that registers development fakes for every unclaimed core service slot, including ones that report success for work they never did, and it had no environment check of its own: an `objectstack.config.ts` carrying `new DevPlugin()` into a production deploy got the whole fake slate with only a boot log to say so. `init()` now throws there. Set `OS_ALLOW_DEV_PLUGIN=1` if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable). + + FROM → TO: a stack that relied on the dev security stubs was not being protected by them — it was being told everything was allowed. Install `@objectstack/plugin-security` to enforce RBAC/RLS/masking, or accept the empty slots (unchanged behaviour on every path that already handled an absent SecurityPlugin). A production process that loaded `plugin-dev` must now either drop it and install the real services, or opt in explicitly with `OS_ALLOW_DEV_PLUGIN=1`. + + Also: `plugin-hono-server`'s `/auth/me/permissions` resolves `security.permissions` and `metadata` through the same guarded lookup its three sibling lookups already used. An unregistered slot makes `getService` throw, which previously landed in the outer catch — the same fail-open response body, but logged as "/auth/me/permissions failed" on every console navigation instead of taking the deliberate `!evaluator` branch. + +- 839982e: fix(plugin-hono-server): compute the standalone discovery `routes` from real registrations, and cede to the real owner (#4018) + + `registerStandardEndpoints` served a **fully static** discovery: a hardcoded + `routes` table listing `auth` / `packages` / `analytics` / `workflow` / + `automation` / `ai` / `notifications` / `i18n` / `storage` / `ui` regardless of + what the host actually mounted. A standalone Hono deployment therefore + advertised ten route families and 404'd on every one no plugin bridged — the + "advertise a route that doesn't exist" class ADR-0076 D12 exists to kill, and + the reason this surface disagreed with the two real discovery builders + (`HttpDispatcher.getDiscoveryInfo`, `metadata-protocol`'s `getDiscovery`), which + both compute per service at runtime. + + Two changes, no new discovery implementation to keep in sync: + + - **Single owner (D11 / OQ#9).** When `@objectstack/rest` or the runtime + dispatcher is on the kernel, this surface no longer registers + `${prefix}/discovery` — that plugin owns it. Both register during plugin + `start()`, i.e. before this `kernel:ready` hook, and Hono is + first-registration-wins, so they already shadowed this handler in every + composed deployment: the cede changes no served payload, it removes a third + one nobody read. `/.well-known/objectstack` is ceded to the dispatcher only + (REST never registers it), so a REST-without-dispatcher host keeps the + redirect. + + - **Computed, not hardcoded (D12).** When this surface does own `/discovery`, + `routes` is derived per request from the app's live route table: a family is + advertised iff a route is really registered at or under its base path. A + wildcard mounted _above_ the base (global `/*` middleware, `/api/v1/*`) does + not count as a mount. + + **What changes for you.** On a standalone `HonoServerPlugin` host (no REST, no + dispatcher), `GET /api/v1/discovery` now omits every family nothing mounts — + most visibly `routes.metadata`, since `/api/v1/meta` ships with + `@objectstack/rest` / the dispatcher. Clients that read a route out of + discovery and call it stop getting a 404; `@objectstack/client` falls back to + the conventional path for any omitted key, so `client.connect()` is unaffected. + Composed deployments (`os serve`, cloud) are unchanged — the dispatcher's + service-aware discovery was already the one being served. + +- f985b3f: fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + + **`packages/core/src/contracts/` was a dead near-copy of the real contracts, + and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had + ZERO importers — no relative import, no subpath export, not a tsup entry; + core's barrel has re-exported the `@objectstack/spec/contracts` versions all + along ("Re-export contracts from @objectstack/spec for backward + compatibility"). But the shadow had already **diverged** from the live + contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew + `rawBody?`; the copy never did), so anyone who grepped their way into it read a + stale contract that nothing enforces — the exact both-humans-and-AI failure + mode behind the false `http.server` exemption (#4382). Deleting it is + zero-risk by construction: nothing could reach it. + + **`http.server` is the canonical slot name, and the ledger now says so.** + `ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated + `'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin + register both two lines apart; cloud's two server entrypoints do the same). + Canonical is the only name present on EVERY provider path: runtime's + `config.server` path registers no alias, so the three cloud-connection plugins + that read the alias alone (marketplace-proxy, runtime-config, + marketplace-install-local) found an empty slot there — a live miss, now fixed: + all readers go canonical-first with the alias as a fallback that dies with the + alias registrations. The registrations themselves are untouched this release; + both sites now carry the deprecation note. + + **`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle + escape, declared once. Four consumers were each declaring it locally + (cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); + those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` + return is deliberate and documented at the single declaration: the handle's + real type belongs to the framework, and naming it would give the contract a + framework dependency. Adapters are not required to expose it; consumers + feature-detect. + + **`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options + their implementation has always accepted.** `bulkRegister`'s contract options + dropped the `MetadataWriteOptions` half its implementation intersects in + (`notify` is destructured on the method's first line); `bulkUnregister` + declared no options at all while the manager takes them. Same shape as the + `IDataEngine` read-methods gap from B2: a caller typed to the contract could + not reach the channel without erasing the lookup. Both additive; no implementor + or caller breaks. + + Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup + typed while touched). + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- c54c822: fix(spec,plugins): sweep the auth/session slot lookups — 31 sites typed, and the user-import metadata reader was pointed at a service that never had the method (#4251) + + Batch B2 of the #4251 sweep: every service-lookup erasure in the auth/session + family. `plugin-auth/auth-plugin.ts` (20), `plugin-hono-server/current-user-endpoints.ts` + (10) and `plugin-security/security-plugin.ts` (1) now pass the slot's contract + type; the ratchet baseline drops **171 → 140 sites, 40 → 37 files**. + + **The yield.** `POST /admin/import-users` resolved the `metadata` slot and probed + `metadataService?.getMetaItem` to decide whether to pass the import's field-coercion + dependency. `getMetaItem` is a **protocol** method — `ObjectStackProtocolImplementation`, + registered by MetadataProtocolPlugin under the `protocol` slot. `MetadataManager`, + which occupies `metadata`, has never had it. So the probe was false on every + deployment and the dep was never passed: imported rows reached `sys_user` + uncoerced, with the branch that says otherwise sitting right there. This is the + same shape as #4127's dead `automation.trigger` and #4321's `registerInMemory` + probes — a capability the code advertises and the runtime cannot deliver, kept + invisible by the `any`. Typing the lookup to `IMetadataService` is what turned it + into a compile error. The route reads `protocol` now. + + `/me/apps` reached ObjectQL's **private** `_registry` through `as any` while + `/auth/me/permissions`, two handlers up in the same file, read the public + `registry` getter over the same field of the same object. Both read the public + accessor now; the one test that stubbed `_registry` was pinning the private reach + and stubs `registry` instead. + + **Contract, from evidence.** `IDataEngine`'s read methods (`find` / `findOne` / + `count` / `aggregate`) declare the trailing `options?: BaseEngineOptions` + argument they have always accepted. ObjectQL's own doc explains why it exists: + reads once took their context inside the query while writes took it in trailing + `options.context`, so the same `{ context }` object was correct as `insert`'s 3rd + argument and **silently dropped** as `find`'s — "an intended `isSystem` bypass + just vanished". The engine accepts both channels; the contract exposed only the + query one, so callers using the trailing channel — the current-user endpoints' + permission-set loader among them — could only reach it by erasing the lookup. + Adding an optional trailing parameter breaks no implementor (the existing + minimal-implementation test proves it) and no caller. `BaseEngineOptions` was + already exported, sitting unused under the "legacy/deprecated" heading, which is + why the contract went looking and did not find it; it moves up beside the other + QueryAST-aligned types with the rationale attached. One new spec test pins the + trailing argument at the call site — the position where the old contract rejected it. + + **Where the contract does not reach, the escape hatch is named.** Three slots + resist a spec type today and each gets a narrow, documented local interface + instead of `any`: `security.permissions` (plugin-security's `PermissionEvaluator` + — plugin-hono-server must not depend on an optional plugin), `settings` + (service-settings' resolver, same reason), and ObjectQL beyond `IDataEngine` + (`registry` / `getSchema` / `registerHook` / `registerMiddleware`). That last one + is deliberate scope: the standing record on `getObjectQL` in `@objectstack/runtime` + says ObjectQL is genuinely wider than `IDataEngine` and nobody has written the + wider contract, so typing the whole thing `IDataEngine` would be "the more + comfortable-looking lie". These declarations are what that contract gets written + from, and what it deletes. + + No behavior changes beyond the two fixes above. + +- 2053714: fix(hono,plugin-hono-server,runtime): one CORS source and one registry key — the last derivable copies from the #3786 sweep + + Re-ran the sweep across all 72 packages. The earlier pass globbed `packages/*/src`, + which is one level deep, so it missed everything under `packages/plugins/` and + `packages/adapters/` — the "sweep is basically clean" report was based on an + incomplete scan. + + **A stale CORS default, on the one description callers actually read.** + `HonoCorsOptions.allowHeaders`' TSDoc promised + `['Content-Type', 'Authorization', 'X-Requested-With']` "which is sufficient for + cookie and bearer-token auth". The real default carries three more: + `X-Tenant-ID` and `X-Environment-Id` (multi-tenant routing) and `If-Match` (the + OCC token on record PATCHes, objectui#2572). Sizing a custom `allowHeaders` + against that sentence drops all three and every cross-origin save fails with + "Failed to fetch". + + The instructive part: **three** Hono CORS sites each carried their own copy of + the defaults under "keep in sync" comments, and the copies all agreed. What + drifted was the _doc_ — the only description with no counterpart to be diffed + against, and the only one a caller reads. + + Both defaults are now single constants, `DEFAULT_CORS_ALLOW_HEADERS` and + `DEFAULT_CORS_EXPOSE_HEADERS`, exported from `@objectstack/plugin-hono-server` + and imported by the adapter (which already depends on it — no new edge). The + TSDoc links them rather than restating, and documents an asymmetry it never + mentioned: `allowHeaders` REPLACES the default, `exposeHeaders` MERGES with it. + + `hono-plugin.test.ts` stopped stubbing `./adapter` wholesale and keeps the real + constants via `importOriginal` — it asserts exact header lists, so a mocked copy + would make the test agree with itself rather than with what ships. Verified: + removing `If-Match` from the constant fails `should allow If-Match by default`, + by name. + + **A third copy, in the public protocol docs.** `content/docs/protocol/kernel/ +http-protocol.mdx` advertised `Access-Control-Allow-Headers: Authorization, +Content-Type` — two of the six — and methods missing `PUT` and `HEAD`, with no + mention of the exposed headers at all. That is the copy an integrator builds a + client against: reading it, you would not know `If-Match` is permitted (so you + would not attempt OCC) or that `set-auth-token` is readable (so a rotated + session would look like a bug). Corrected, with the three non-obvious allowed + headers and the two exposed ones explained, and a pointer to the constants as + the source of truth. + + **A hand-copied service-registry key.** `runtime`'s share-links domain resolved + `'shareLinks'` as a string literal, copied from `SHARE_LINK_SERVICE` — whose own + doc-comment says "keep in sync with the SharingPlugin registration". It now + imports the constant. A drifted copy resolves nothing, so every share link + answers 501 "Sharing is not configured for this environment" on an environment + where it is configured perfectly well. + + **Plus a duplicate ledger entry**, which is the same defect one level up: + `check-generated.ts` carried two `NO_GENERATOR` entries for + `check:strictness-ledger`, because #4203 and #4252 each added one without seeing + the other. Functionally harmless (the ledger is read into a `Set`) but it leaves + two comments telling overlapping versions of the same story. #4203's is kept — + it is the more complete account and it is the PR that fixed the underlying + problem. + + Checked and deliberately left alone: `ApprovalStatus` (5 values) and + `ApprovalActionKind` (12 values) versus their `plugin-approvals` selects — diffed + verbatim, no drift today, still hand-copied across a package boundary. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index d8cfe6f3ee..5ccf0350dd 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 0b07e7d88d..4753e35e22 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,169 @@ # @objectstack/plugin-pinyin-search +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363) + + #4363 made a single paged read a partition of its result set. It could not make + a _walk_ one: seven background scans paged with a growing `offset` while writing + to the very rows they were reading, and an offset counts into a set those writes + are changing. Rows slide past the cursor and are never visited. + + That is not a slow page in any of these — it is a wrong answer wearing the shape + of a clean run: + + - **`rebuildApproverIndex`** built its desired state by walking + `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then + **deleted** every index row that state did not explain. A skipped request + meant an approver silently dropped from someone's queue. (The loop beside it + ordered by `created_at` — not unique, so its pages were never a partition + either.) + - **`verifyFileReferences`** decides which files nothing references. A record it + never visits is reported as an unreferenced file. + - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite + each row they read, so their own writes were shifting the set out from under + the cursor. Records were left unconverted and unsearchable by a run that + reported success. + - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and + it opens a migration gate on that evidence. + + All of them now go through `keysetWalk` (`@objectstack/types`): order by a + unique key, and seek past the last one instead of counting from the start. A + row's key does not move when the row is updated, and cannot be shifted when + another is deleted, so the walk is stable under exactly the mutation these + functions perform. It is also O(n) rather than O(n²/page) — measured on + Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek. + + One deliberate non-conversion: the REST **export** stream keeps its offset. It + honors a caller-chosen sort, and a keyset walk would have to re-order the export + by `id` to seek — changing what the user asked for to fix a cost. Its pages are + already a partition since #4363; only the depth cost remains. + + `keysetWalk` merges the cursor with `$and` rather than spreading it into the + caller's filter, so a walk whose own `where` constrains the key column + (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently + overwritten. When a `max` cap is set it reads one row beyond the cap to tell + "the cap stopped us" from "the source ended exactly there" — without that, a + walk that read everything still reports `truncated`, and a caller acting on it + goes looking for rows that were never withheld. + + The storage suites' fake engines now **throw** on an `offset` instead of serving + one, so the conversion is pinned rather than merely passing. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [48fcf70] +- Updated dependencies [3ec8186] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [32ccb23] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [8d895ff] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [3c628ce] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [91f4c78] +- Updated dependencies [45dc446] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [39eb01b] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [77fadbf] +- Updated dependencies [5c13368] +- Updated dependencies [857a6cf] +- Updated dependencies [d5749d7] +- Updated dependencies [d92c72d] +- Updated dependencies [5d21a48] +- Updated dependencies [e4c2dc8] + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index a3f2607509..742ba5c142 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 7d9d64a9df..a2bd688320 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,261 @@ # @objectstack/plugin-reports +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index a85d7ae6a4..7eb5555225 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 4334ee77b5..dbfe14f919 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,588 @@ # @objectstack/plugin-security +## 17.0.0-rc.1 + +### Minor Changes + +- 1ea6bce: feat(sharing): hierarchy managers may manage shares within their write DEPTH (ADR-0111 D1 DEPTH) + + `canManageShares` gains its named DEPTH extension: a caller whose effective + WRITE scope on the object is a hierarchy scope (`unit` / `unit_and_below` / + `own_and_reports`) may now manage shares on a record whose owner falls within + that scope's owner set — the same set the write filter and `canEdit` already + honour, resolved by the enterprise `hierarchy-scope-resolver`. This lets a + manager grant/revoke/list shares on a subordinate's record, matching + Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU + depth), without expanding the MVP owner + Modify-All authority. + + - New `ISecurityService.resolveWriteScope(object, context)` — the effective + write scope, resolved by the same evaluator the CRUD middleware uses; fails + closed to `own`. Mirrored on the sharing plugin's structural probe. + - The gate honours only the three hierarchy scopes. `org` from the probe is + deliberately ignored: it means both a genuine Modify-All holder (already + granted via `hasWriteBypass`) AND the fail-OPEN "no permission set mentions + this object" default, so honouring it here would reopen the hole + `hasWriteBypass` was chosen to avoid. + - Fails closed with no security service or no enterprise resolver — the open + edition stays owner + Modify-All, exactly as before. + +- c1dcacd: fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902) + + Record sharing shipped as a data layer with no authorization of its own: every + `/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller + and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke + anyone's share, enumerate who-can-see-what, write self-grants, and define / + evaluate org-wide sharing rules. ADR-0111's P0 rulings land here: + + - **D1/D2** — `ISharingService.canManageShares(object, recordId, context)`: + system, the record's owner, or a holder of Modify All Data (probed via the + new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE, + so every caller is covered; without plugin-security it fails closed to + owner-only. + - **D4** — `revoke` is symmetric with grant, validates the share belongs to the + URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows + (`CONFLICT` — a rule-materialised grant would be resurrected by the next + reconcile). + - **D5** — `listShares` is management-gated (invisible record → `NOT_FOUND`, + visible-but-not-manager → `PERMISSION_DENIED`), and the open + `/data/sys_record_share` read surface is self-scoped: non-admin callers see + only rows naming them as recipient or grantor. + - **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate) + requires the new **`manage_sharing`** capability (D9; seeded into + `admin_full_access`, `manage_platform_settings` honoured as the legacy + equivalent), enforced in `SharingRuleService`. + - **D7** — no inert grants: `recipientType` is narrowed to `user` (the only + type any gate enforces), grants on objects the sharing gates never consult + (public model, no `owner_id`, bypass, `controlled_by_parent`) fail with + `SHARING_NOT_ENABLED` (422), and the manual upsert keys on + `(object, record, recipient, source)` so manual and rule rows coexist. + + **Breaking** for callers that relied on the missing gate: unauthorized share + management now fails with 403/404/409/422 instead of silently succeeding, and + `ISharingService.revoke` gained an optional `scope` parameter. The verb + boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the + separate P1. + +- ad303ed: fix(sharing)!: an edit-level share no longer grants delete (ADR-0111 D3, the verb boundary) + + `update` and `delete` shared one `canEdit` gate, and `canEdit` accepts an + `edit`-level share — so one "edit" grant silently conferred delete, the + opposite error from the retired `full` level. A share widens _which rows_ a + principal reaches, never _which verbs_ they may use (Salesforce Read/Write + cannot delete; Dataverse `Delete` is a distinct privilege; Odoo splits + `write`/`unlink`). + + - `ISharingService.canDelete(object, recordId, context)` — ownership (widened + by write DEPTH) or the `modifyAllRecords` super-user bypass ONLY; an `edit` + or legacy `full` share does not confer it. `canEdit` is unchanged (the + update gate, share included). + - `SharingService.buildWriteFilter` takes a `verb` parameter: a bulk + `delete({multi:true})` scopes to the owner/DEPTH set alone (no share + widening), while a bulk `update` keeps it. + - The sharing middleware routes `delete` through `canDelete` and logs a + specific fail-closed reason on denial (ADR-0111 D10). + - `/security/explain` consults `canDelete` for a `delete` operation, so the + record-level explanation matches enforcement. + + **Breaking**: a caller who could delete a record _only_ through an edit-level + share (and holds object-level delete CRUD) can no longer delete it — delete now + requires ownership, write depth, or Modify All Data. No new delete access level + is introduced; a future per-record delete grant would be a capability mask + AND-ed with object CRUD, not a fourth share level. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- 5b47ab5: refactor(data)!: the QueryAST request surface stops declaring what no executor runs — `joins` and `windowFunctions` removed, six search flags and `aggregations[].filter` marked experimental, and the liveness ledger now governs the query surface (#4286) + + #4196 removed one declared-but-inert member from `FieldNode`. Applying the same + method to the rest of the request surface (#4286) found 12 more members of + `QueryAST` that no executor runs — `packages/objectql`'s `engine.ts` contains + zero reads of any of them on the query path. This change dispositions the + mechanical tiers and closes the gate that let the class stay invisible. + + **Removed (tombstoned): `query.joins` and `query.windowFunctions`.** + + - `joins` — no engine or driver ever read it; a query carrying it silently ran + as a single-table query. Related-record retrieval already has a live + spelling: `expand`. The orphaned `JoinNode` / `JoinNodeInput` / + `JoinNodeSchema` / `JoinType` / `JoinStrategy` exports are deleted with the + key (`data/JoinNode`, `data/JoinType`, `data/JoinStrategy` leave the + published JSON schemas). + - `windowFunctions` — `find()` never applied it, so every OVER clause it + declared was silently dropped. The one live door is the SQL driver's own + `findWithWindowFunctions(object, query)` (driver-level, not on the + `IDataDriver` contract), and its input is a flat driver shape the spec + vocabulary never matched — `WindowFunctionNodeSchema` declared `field` / + `over` / `frame` members that door never read. The `WindowFunction` / + `WindowSpec` / `WindowFunctionNode` exports are deleted with the key. + + **FROM → TO** + + | Was | Now | + | :------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------- | + | `joins: [{ type: 'inner', object: 'customer', on: … }]` | `expand: { customer_id: { object: 'customer', fields: ['name'] } }` | + | `joins` for one related column | `fields: ['customer_id.name']` (dotted path) | + | `windowFunctions: [{ function: 'rank', … }]` in a query | `aggregations` + `groupBy`, or rankings in report/dashboard metadata | + | OVER-clause SQL from an embedder | `sqlDriver.findWithWindowFunctions(object, { windowFunctions: [{ function, alias, partitionBy?, orderBy? }] })` | + + The one-line fix: **delete the key**. Both are `retiredKey()` tombstones on the + non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type + `never`) and a query still carrying one — even as an empty array — fails to + parse with the prescription itself. `QueryAST` is a request shape, never stored + in stack metadata, so there is no `os migrate meta` step: the removals are + registered as protocol-17 **semantic** migrations (`query-joins-retired`, + `query-window-functions-retired`), the #4196 precedent. + + Compat note for the REST boundary: both names remain **reserved** list-query + parameters while the tombstones live (`retiredKey()` keeps a key in + `keyof QueryAST`, which feeds `RESERVED_LIST_QUERY_PARAMS`), so nothing changes + for objects with fields named `joins`/`windowFunctions` — the un-reservation + happens when the tombstones age out, and is called out in + `metadata-protocol`'s `QUERY_AST_KEYS` comment for whoever does it. + + **Marked `[EXPERIMENTAL — not enforced]` (no wire or compat impact):** + `search.fuzzy` / `operator` / `boost` / `minScore` / `language` / `highlight` + (the ADR-0061 expansion reads only `query` + `fields`) and + `AggregationNode.filter` (a SQL `FILTER (WHERE …)` affordance neither the SQL + builders nor the in-memory fallback applies). Authoring one is now a + declaration, not a silent no-op. + + **Deliberately NOT dispositioned here** (they want a maintainer call, #4286 + steps 3–4): `having` (the strongest enforce candidate — `engine.aggregate()` + currently rebuilds the driver AST without it), and `cursor` / `distinct` + (shipped SDK producers `QueryBuilder.cursor()` / `.distinct()`; `distinct` is + mis-wired — its only observable effect is suppressing the REST list count). + All three are recorded `dead` with evidence in the new ledger. + + **The gate:** `QuerySchema` joins the liveness ledger through the gate's + `SPEC_ONLY_SCHEMAS` override (the `webhook` precedent) as governed type + `query` — the first governance of what _callers_ write into a query rather + than what authors write into metadata files. `packages/spec/liveness/query.json` + classifies all 27 walked members (15 live with evidence, 7 experimental via + describe markers, 5 dead), so the next declared-but-inert request member fails + CI instead of needing a person to notice it. + + `@objectstack/plugin-security` (patch): the FLS predicate guard's + `windowFunctions` walk is pruned — the clause no longer exists to leak through. + The `having` and `aggregations[].filter` walks stay, deliberately: those + members remain declared, and the guard being ready is what makes enforcing + them later safe. + +- 94a0bbc: fix(security)!: a disabled RLS policy no longer grants — found by re-verifying the ledger's security subset (#3896 follow-up) + + **The fix.** `RowLevelSecurityPolicySchema.enabled` promises, verbatim: _"Disabled + policies are not evaluated."_ Nothing read it — not the collection site, not the + projection round-trip, not the compiler. Because applicable policies OR-combine + (any match allows access), a policy an admin switched off **kept contributing its + grant**: disabling a too-permissive policy silently changed nothing. That is the + #3896 shape — a documented security control whose real behaviour is wider than + its contract — one layer up, on RLS instead of sharing rules. + + `getApplicablePolicies` now excludes `enabled === false` before any matching, at + the single choke point both the find path and the analytics path flow through — + the same place, and the same ADR-0049 enforce-or-remove resolution, as the + formerly-unenforced `positions` domain. Exact `=== false` on purpose: the schema + defaults `enabled` to true and projection rows may omit the key, so absent stays + active. Four tests pin both directions. Access-narrowing only: no policy grants + MORE after this change, and nothing in-repo authors `enabled: false`. + + **The audit that found it.** All 44 entries of the liveness ledger's security + subset (`permission` 33, `position` 4, `object` sharing/access 7) were + call-graph-closed by hand and stamped `verifiedAt: 2026-07-30` — the subset's + first-ever re-verification (previously 4 dated entries repo-wide, and the last + sweep that cited preview renderers went 10-for-13 wrong). Beyond `enabled`: + + - `rowLevelSecurity.priority` → **dead + authorWarn**. Not merely unimplemented: + policies OR-combine (the schema's own describe says most-permissive-wins), so + the promised "conflict resolution" semantics cannot exist. A REMOVE candidate + per the #3715/#3950 precedent while the v17 breaking window is open. + - `rowLevelSecurity.label` / `description` / `tags` → dead (benign display — + no consumer in either repo; deliberately not authorWarn'd). + - `tabPermissions` was UNDERSTATED: the note said only `'hidden'` is read, but + hono's rank merge reads all four visibility values across resolved sets, and + the `me-apps-and-everyone-baseline` dogfood test exercises it. Evidence + upgraded; noted as a proof-binding candidate. + - `allowExport` re-verified TRUE against the suspicion that it was + projection-only: the export route carries its own caller-level 403 gate + (`enforceExportPermission`), fail-closed when the security service cannot + answer, separate from the object-level 405. + - `allowTransfer/Restore/Purge` notes re-confirmed accurate (M2 operations still + unshipped; the RBAC gates are pre-mapped fail-closed). + - `object.ownership` evidence had rotted (line drift) — refreshed; six other + object-level security entries re-cited and stamped. + + No other runtime behaviour changes. + +- d92c72d: fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + + The three selectors from #4321 all key off the erasure and the lookup being in + ONE expression. Split them and every selector misses: + + ```ts + let ql: any; + try { + ql = ctx.getService("objectql"); + } catch { + /* optional */ + } + ``` + + Selector 1 needs the call inside the declarator (this declarator has no init), + selector 2 needs `as`, selector 3 needs a type argument. The contract is erased + exactly as in `const ql: any = ctx.getService(…)`. + + **Why this could not wait for the batches.** The baseline's monotonicity check + means a file that leaves the grandfather list can never be re-added. So every + batch converted more of this shape from "grandfathered" into "lint covers this + file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` + into that state. A ratchet that reports a cleaner number the more you sweep is + the #4342 failure wearing different clothes, and the fix only gets more + expensive per batch shipped. + + **It is a rule, not a fourth selector, and that is the whole finding.** esquery + can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell + which declaration the assigned identifier resolves to — so it would equally + flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in + runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the + identifier needs SCOPE analysis. That is cheap and needs no type information, so + this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it + is a rule, and the earlier "just one more selector" estimate was wrong. + + Verified against exactly that: the rule flags all 16 real sites and none of the + 8 correctly-typed lookalikes. + + **Scale.** The baseline goes 140 → **169 sites** with the file count unchanged + at 37: 29 sites were already inside grandfathered files and simply invisible. + 16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 + in files never listed, 1 the regex sweep had missed) and are typed here — + `runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, + `plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, + `runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was + added; the key set still only shrinks. + + Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, + `II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they + do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, + `EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of + those record something worth naming: `IHttpServer` has no `getRawApp()` (the + contract is framework-agnostic and the raw app is Hono's own handle), and + ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader + at all — the engine attaches them via `(this as any)` and publishes nothing, + while `getHookMetricsRecorder()` exists for exactly that question about the + metrics recorder. Declared rather than laundered through `any`, and filed. + +- c54c822: fix(spec,plugins): sweep the auth/session slot lookups — 31 sites typed, and the user-import metadata reader was pointed at a service that never had the method (#4251) + + Batch B2 of the #4251 sweep: every service-lookup erasure in the auth/session + family. `plugin-auth/auth-plugin.ts` (20), `plugin-hono-server/current-user-endpoints.ts` + (10) and `plugin-security/security-plugin.ts` (1) now pass the slot's contract + type; the ratchet baseline drops **171 → 140 sites, 40 → 37 files**. + + **The yield.** `POST /admin/import-users` resolved the `metadata` slot and probed + `metadataService?.getMetaItem` to decide whether to pass the import's field-coercion + dependency. `getMetaItem` is a **protocol** method — `ObjectStackProtocolImplementation`, + registered by MetadataProtocolPlugin under the `protocol` slot. `MetadataManager`, + which occupies `metadata`, has never had it. So the probe was false on every + deployment and the dep was never passed: imported rows reached `sys_user` + uncoerced, with the branch that says otherwise sitting right there. This is the + same shape as #4127's dead `automation.trigger` and #4321's `registerInMemory` + probes — a capability the code advertises and the runtime cannot deliver, kept + invisible by the `any`. Typing the lookup to `IMetadataService` is what turned it + into a compile error. The route reads `protocol` now. + + `/me/apps` reached ObjectQL's **private** `_registry` through `as any` while + `/auth/me/permissions`, two handlers up in the same file, read the public + `registry` getter over the same field of the same object. Both read the public + accessor now; the one test that stubbed `_registry` was pinning the private reach + and stubs `registry` instead. + + **Contract, from evidence.** `IDataEngine`'s read methods (`find` / `findOne` / + `count` / `aggregate`) declare the trailing `options?: BaseEngineOptions` + argument they have always accepted. ObjectQL's own doc explains why it exists: + reads once took their context inside the query while writes took it in trailing + `options.context`, so the same `{ context }` object was correct as `insert`'s 3rd + argument and **silently dropped** as `find`'s — "an intended `isSystem` bypass + just vanished". The engine accepts both channels; the contract exposed only the + query one, so callers using the trailing channel — the current-user endpoints' + permission-set loader among them — could only reach it by erasing the lookup. + Adding an optional trailing parameter breaks no implementor (the existing + minimal-implementation test proves it) and no caller. `BaseEngineOptions` was + already exported, sitting unused under the "legacy/deprecated" heading, which is + why the contract went looking and did not find it; it moves up beside the other + QueryAST-aligned types with the rationale attached. One new spec test pins the + trailing argument at the call site — the position where the old contract rejected it. + + **Where the contract does not reach, the escape hatch is named.** Three slots + resist a spec type today and each gets a narrow, documented local interface + instead of `any`: `security.permissions` (plugin-security's `PermissionEvaluator` + — plugin-hono-server must not depend on an optional plugin), `settings` + (service-settings' resolver, same reason), and ObjectQL beyond `IDataEngine` + (`registry` / `getSchema` / `registerHook` / `registerMiddleware`). That last one + is deliberate scope: the standing record on `getObjectQL` in `@objectstack/runtime` + says ObjectQL is genuinely wider than `IDataEngine` and nobody has written the + wider contract, so typing the whole thing `IDataEngine` would be "the more + comfortable-looking lie". These declarations are what that contract gets written + from, and what it deletes. + + No behavior changes beyond the two fixes above. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index b24a527cc3..2f58c5b8b6 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 354b9d946b..fe0eb9be4c 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,638 @@ # @objectstack/plugin-sharing +## 17.0.0-rc.1 + +### Minor Changes + +- 1ea6bce: feat(sharing): hierarchy managers may manage shares within their write DEPTH (ADR-0111 D1 DEPTH) + + `canManageShares` gains its named DEPTH extension: a caller whose effective + WRITE scope on the object is a hierarchy scope (`unit` / `unit_and_below` / + `own_and_reports`) may now manage shares on a record whose owner falls within + that scope's owner set — the same set the write filter and `canEdit` already + honour, resolved by the enterprise `hierarchy-scope-resolver`. This lets a + manager grant/revoke/list shares on a subordinate's record, matching + Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU + depth), without expanding the MVP owner + Modify-All authority. + + - New `ISecurityService.resolveWriteScope(object, context)` — the effective + write scope, resolved by the same evaluator the CRUD middleware uses; fails + closed to `own`. Mirrored on the sharing plugin's structural probe. + - The gate honours only the three hierarchy scopes. `org` from the probe is + deliberately ignored: it means both a genuine Modify-All holder (already + granted via `hasWriteBypass`) AND the fail-OPEN "no permission set mentions + this object" default, so honouring it here would reopen the hole + `hasWriteBypass` was chosen to avoid. + - Fails closed with no security service or no enterprise resolver — the open + edition stays owner + Modify-All, exactly as before. + +- e5e8b10: feat(sharing): a record's share-manager may revoke any share-link on that record (ADR-0111 D8) + + `ShareLinkService.revokeLink` was creator-or-system only, so a record's owner or + a Modify-All admin could not kill a link someone else minted on their record — + their record's exposure, but not their link to revoke. Revoke authority now + also admits a record **share-manager**, probed via the sharing service's + late-bound `canManageShares` (owner / `modifyAllRecords`). The probe fails + closed: a deployment without it (or a throwing probe) keeps the pre-D8 + creator-only behaviour. Mint authority is unchanged and now documented as the + D8 decision it always enforced — the object's `publicSharing` opt-in AND the + caller's visibility of the record. + +- c1dcacd: fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902) + + Record sharing shipped as a data layer with no authorization of its own: every + `/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller + and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke + anyone's share, enumerate who-can-see-what, write self-grants, and define / + evaluate org-wide sharing rules. ADR-0111's P0 rulings land here: + + - **D1/D2** — `ISharingService.canManageShares(object, recordId, context)`: + system, the record's owner, or a holder of Modify All Data (probed via the + new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE, + so every caller is covered; without plugin-security it fails closed to + owner-only. + - **D4** — `revoke` is symmetric with grant, validates the share belongs to the + URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows + (`CONFLICT` — a rule-materialised grant would be resurrected by the next + reconcile). + - **D5** — `listShares` is management-gated (invisible record → `NOT_FOUND`, + visible-but-not-manager → `PERMISSION_DENIED`), and the open + `/data/sys_record_share` read surface is self-scoped: non-admin callers see + only rows naming them as recipient or grantor. + - **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate) + requires the new **`manage_sharing`** capability (D9; seeded into + `admin_full_access`, `manage_platform_settings` honoured as the legacy + equivalent), enforced in `SharingRuleService`. + - **D7** — no inert grants: `recipientType` is narrowed to `user` (the only + type any gate enforces), grants on objects the sharing gates never consult + (public model, no `owner_id`, bypass, `controlled_by_parent`) fail with + `SHARING_NOT_ENABLED` (422), and the manual upsert keys on + `(object, record, recipient, source)` so manual and rule rows coexist. + + **Breaking** for callers that relied on the missing gate: unauthorized share + management now fails with 403/404/409/422 instead of silently succeeding, and + `ISharingService.revoke` gained an optional `scope` parameter. The verb + boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the + separate P1. + +- ad303ed: fix(sharing)!: an edit-level share no longer grants delete (ADR-0111 D3, the verb boundary) + + `update` and `delete` shared one `canEdit` gate, and `canEdit` accepts an + `edit`-level share — so one "edit" grant silently conferred delete, the + opposite error from the retired `full` level. A share widens _which rows_ a + principal reaches, never _which verbs_ they may use (Salesforce Read/Write + cannot delete; Dataverse `Delete` is a distinct privilege; Odoo splits + `write`/`unlink`). + + - `ISharingService.canDelete(object, recordId, context)` — ownership (widened + by write DEPTH) or the `modifyAllRecords` super-user bypass ONLY; an `edit` + or legacy `full` share does not confer it. `canEdit` is unchanged (the + update gate, share included). + - `SharingService.buildWriteFilter` takes a `verb` parameter: a bulk + `delete({multi:true})` scopes to the owner/DEPTH set alone (no share + widening), while a bulk `update` keeps it. + - The sharing middleware routes `delete` through `canDelete` and logs a + specific fail-closed reason on denial (ADR-0111 D10). + - `/security/explain` consults `canDelete` for a `delete` operation, so the + record-level explanation matches enforcement. + + **Breaking**: a caller who could delete a record _only_ through an edit-level + share (and holds object-level delete CRUD) can no longer delete it — delete now + requires ownership, write depth, or Modify All Data. No new delete access level + is introduced; a future per-record delete grant would be a capability mask + AND-ed with object CRUD, not a fourth share level. + +- ccd9397: fix(security)!: a sharing rule with no criteria now shares NOTHING instead of every record (#3896) + + `SharingRuleSchema` has always required `condition`, and its doc is explicit + that a predicate the compiler cannot lower is _"skipped and logged — never + seeded as a permissive match-all (ADR-0049)"_. The declared/seed path honoured + that. The two other ways to create a rule did not: + + - **`POST {basePath}/sharing/rules`** plucks its body field-by-field into + `SharingRuleService.defineRule`, which validated `name` / `label` / `object` / + `recipientType` / `recipientId` — and not `criteria`. A missing, `null`, or + **misspelled** key (`criterias`) was stored as `criteria_json: null`, answered + `201` with no warning, and evaluated as + `find(object, { filter: {}, context: SYSTEM_CTX })`: every record of the + object, up to 5000, granted to the recipient. Triggering it took a typo, not + an attacker. + - **Authoring a rule in Setup** is a direct `sys_sharing_rule` insert, which + never reaches `defineRule` at all. + + Empty criteria is now rejected everywhere a rule can be written, and — because + rules created before this gate are already in the table — the evaluator refuses + to act on one regardless of how it got there. + + - **`defineRule` rejects a match-all criteria** with + `VALIDATION_FAILED: criteria is required …`, alongside its other required + fields. Covers the REST endpoint, programmatic callers, and the seeder. + Rejected shapes: missing / `null` / `''` / `{}` / `[]` / `{ $and: [] }` / + unparsable JSON (e.g. a CEL source typed into the Criteria box). + - **The evaluator matches nothing** for such a rule and logs why, so a row + stored before this release under-shares instead of over-sharing: the next + reconcile _revokes_ the grants it had materialised. Both evaluation paths are + covered — the bulk `evaluateRule` and the per-record write-hook path. + - **`bindRuleCriteriaGuard`** fails `sys_sharing_rule` inserts with no + criteria as a field-level `VALIDATION_FAILED` (a 400 naming `criteria_json`), + so the Setup path reports the problem instead of saving an inert rule + (ADR-0078). Updates are checked only when the patch supplies + `criteria_json` — switching an over-broad legacy rule off must not require + inventing a criteria for it first. + - **The seed bootstrap's "empty condition = match-all" branch is gone**: a + missing or empty `condition` is now skipped and logged like any other + non-lowerable one. + - `POST {basePath}/sharing/rules` also accepts `criteria_json` as an alias for + `criteria`, matching the snake_case aliases the endpoint already takes for + `object_name` / `recipient_type` / `access_level`. + + **Migration.** There is no "share every record" sharing rule, and there never + usefully was one — the shape existed only as a failure mode. A rule that + relied on it must state its predicate (`criteria: { stage: 'won' }`), or, if + the object really should be readable by everyone, use the object's + organization-wide default (`sharingModel`) instead. Rules already stored with + a null `criteria_json` need no data migration: they stop granting on the next + evaluation and their existing grants are revoked. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7df7c64: feat(sharing): `sys_sharing_rule.criteria_json` is declaratively required (ADR-0113 P2) + + The field the ADR was written for: `required: true` as the write contract — + insert must provide, update may not null out, legacy null rows rest, an admin + can still `active: false` an over-broad legacy rule. Deliberately NO + `storage.notNull`: deployed tenants' legacy nulls are the case the split + exists for. The Setup form's required marker and client validation now derive + from the declaration. + + Not breaking: a rule without criteria was already rejected by the #3929 hook + guard; the guard narrows to the non-null match-all shapes `required` cannot + express ('{}', vacuous $and/$or, unparsable JSON), `defineRule` keeps the API + seam, and the evaluator stays fail-closed (ADR-0049). + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- 71af9f5: fix(sharing): the criteria-less-rule warn is once per rule per process, plus one boot aggregate (#3929 follow-up) + + Pre-dedup the fail-closed evaluator warned on EVERY pass — per evaluation and + per reconciled write — so one legacy criteria-less rule could dominate a + deployment's log. Enforcement is unchanged (such a rule still matches + nothing and its grants are revoked on reconcile); the warn now fires once + per rule per process, and the boot backfill emits a single operator-facing + aggregate (count + rule names + the fix: repair the criteria or set + active: false). + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- 4580597: fix(plugin-sharing)!: the share-link routes emit the declared envelope, and the last ratchet retires (#3983) + + The fifth and final drifting route module. Unlike the four in #3843, this one was + not found by reading — `scripts/check-route-envelope.mjs` surfaced it the moment + that scan went repo-wide, which is the whole argument for a repo-wide guard over + per-package copies. It also turned out to be the one where the drift had actually + **broken shipped SDK methods**, not merely mis-shaped a body. + + ## Two SDK methods did not work on this surface + + Three of these routes are `disposition: 'sdk'` in `runtime/src/route-ledger.ts`, + and `ObjectStackClient.unwrapResponse` decides a body is an envelope by finding a + boolean `success`. With no flag it hands back the body verbatim: + + | method | documented / typed as | actually returned | + | --------------------- | ------------------------------ | ------------------------------------------- | + | `shareLinks.create()` | "the link row (incl. `token`)" | `{ link: … }` — so `.token` was `undefined` | + | `shareLinks.list()` | `Promise` | `{ links: [] }` — so `.map()` threw | + + `packages/client/src/admin-surfaces.test.ts` mocks all three as + `{ success: true, data: }`. The SDK was written and tested against the + **dispatcher's** shape and only ever worked there. + + ## This is a convergence, not a redesign + + `runtime/src/domains/share-links.ts` serves the same five paths, and for cloud's + per-environment kernels it is the _designed primary_ surface + (`registerShareLinkRoutes: false`). It has always answered in the declared + envelope. The plugin now answers identically: + + | route | was | now | + | -------------------------------- | -------------------------------- | --------------------------------------- | + | `POST /share-links` | `{ link }` | `{ success: true, data: link }` | + | `GET /share-links` | `{ links }` | `{ success: true, data: link[] }` | + | `DELETE /share-links/:idOrToken` | `{ ok: true }` | `{ success: true, data: { ok: true } }` | + | `GET /:token/resolve` | `{ record, link, redactFields }` | `{ success: true, data: { … } }` | + | `GET /:token/messages` | `{ data: rows }` | `{ success: true, data: rows }` | + | errors | `{ error: { code, message } }` | `{ success: false, error: { … } }` | + + `data` carries each payload **directly** — `data: links`, not `data: { links }`. + That is what makes `unwrapResponse` return the same value on both surfaces, and + it is what the SDK already expected. + + ## Breaking: raw `fetch` callers add one hop + + SDK callers get the fix for free (two of them go from broken to working). Direct + body readers add `.data`: + + ```diff + - const { links } = await (await fetch('/api/v1/share-links')).json(); + + const { data: links } = await (await fetch('/api/v1/share-links')).json(); + ``` + + `{ ok: true }` on revoke survives, but as the payload rather than as the body: at + the top level it was a second word for `success`, which #3689 retired from + storage; under `data` it is what the dispatcher already returned. + + The `error` half was already nested `{ code, message }` — #3675's changeset cited + this module as the good example of that — so only the `success` flag is new there. + All eleven codes were already SCREAMING_SNAKE and registered, so ADR-0112 needs + nothing. + + ## Consumers + + Swept, and the result is smaller than #3983 assumed. The framework has **zero** + consumers of these routes. In objectui, `ShareDialog` was already dual-shape + tolerant on all three routes it calls (`body.links ?? body.data`, + `created.link ?? created.data`, and revoke never reads the body) — it needs no + change, and it carried that tolerance precisely _because_ both shapes existed in + the fleet. + + `SharedRecordPage` did need one fix, and it is the kind a shape-swap would have + missed: it renamed the wire's `redactFields` to `redactedFields` only on the + _bare_ branch, so on the already-enveloped dispatcher path the "fields are hidden + by the owner" notice never rendered. Converting this surface would have spread + that to every share page. Fixed in objectui#2980, which merges first. + + ## Guard + + **7 conformant / 0 ratcheted / 1 exempt**, from 6 / 1 / 1. The ratchet mechanism + stays for the next module that needs it. + + `privateOk` also got narrowed to what its own doc always claimed — a literal `ok` + at the **top** of a body, where it competes with `success`. The same literal + inside `data` is payload, which is what a conformant revoke returns. Four + self-test assertions pin both readings. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- Updated dependencies [6a67d7a] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [3ec8186] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 349189351a..fc02dd15c1 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 00c4787a12..84aaef89d1 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,384 @@ # @objectstack/plugin-webhooks +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- bb1ce2e: fix(plugin-auth,plugin-webhooks): retire a dead degrade branch and an implicit transitive dependency (ADR-0116 follow-ups, #4187) + + Two concrete findings from the ADR-0116 consumer-side audit, plus the + authoring rule that would have prevented both. + + **`plugin-auth` claimed a fallback it did not have.** `init()` ran + `const dataEngine = ctx.getService('data'); if (!dataEngine) { warn('No data +engine service found - auth will use in-memory storage') }`. That branch could + never execute: `getService` **throws** for an unregistered service rather than + returning `undefined`, and this plugin declares a hard dependency on ObjectQL + (which registers `data` unconditionally), so a kernel without the engine fails + even earlier with `Dependency … not found`. The branch is removed and the real + contract is declared — `requiresServices: ['data', 'manifest']` — which also + replaces a trailing `// manifest service required` comment with the + machine-checked form of the same claim. `AuthManager` keeps its own optional + `dataEngine` guards: it is usable outside the plugin. + + **`plugin-webhook-outbox` was protected only transitively.** It resolves + `manifest` in `init()` with no fallback while depending on + `com.objectstack.service.messaging`, which in turn depends on ObjectQL, the + actual provider. That works today and would have broken silently the day + messaging stopped depending on the engine — surfacing as a crash inside an + unrelated plugin's init. It now declares `requiresServices: ['manifest']` + directly. + + Neither change alters ordering or boot outcomes on any current composition: + both plugins were already ordered correctly. What changes is what a broken + composition _says_, and that the guarantees are now checked rather than + inherited. + + Docs: `content/docs/plugins/anatomy.mdx` gains the three ADR-0116 fields and + the decision rule for resolving a service inside `init()` (hard dependency vs + `optionalDependencies` + `requiresServices`), including the two traps behind + these fixes — don't rely on a transitive provider, and don't write an + `if (!svc)` fallback after a bare `getService`. The api-registry example + declares the contract on all seven of its plugins instead of relying on + `kernel.use()` order. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- a225ef5: fix(runtime,webhooks): the path object wins on /data/:object/query, and the webhook envelope owns its keys (#3946) + + Follow-up sweep for the shape behind #3897 and #3933 — a trusted, server-derived + value written into an object literal with a caller-controlled bag spread OVER + it. Both of those were in the same block of REST code, so the pattern was swept + across all 1313 non-test TypeScript files in `packages/`. Nine candidate sites; + one real, one worth hardening, seven verified clean (recorded in #3946 so the + next sweep does not re-litigate them). + + **`POST /data/:object/query` (runtime dispatcher).** The `/data` domain built + `{ object: objectName, ...body }`, so `{"object":"other", …}` in the body moved + the read to a different object than the URL named. + + This is NOT an authorization bypass, and the tests pin why: `callData` gates + API exposure on `params.object`, so the gate followed the body and agreed with + the read — an object hidden by `apiEnabled: false` was refused either way. What + broke is that the URL stopped describing the operation (audit trails, logs, and + anything keyed on the request path saw object A while object B was read), and + that one endpoint spoke a second dialect of the contract the REST side had just + standardised on: the path object wins. The other handlers in that file never had + the problem — they nest caller data (`data: body`, `query: normalized`) instead + of splatting it, and the GET-by-id branch already allowlists its query params + against exactly this pollution. + + **Webhook delivery envelope.** `auto-enqueuer` built + `{ object, recordId, action, timestamp, ...payload }`, letting an event payload + rewrite the envelope a subscriber receives. Behaviour-neutral for the engine's + own publishers — `data.record.*` payloads are `{ recordId, after, changes }` + with record fields nested under `after`, so none of those four keys collide + today — but the shape was wrong, and the `payload.id` fallback right above it + suggests publishers that flatten record fields do exist. Envelope keys are + written last now. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [a8dcc37] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/service-messaging@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 5d64976315..4ea65d524f 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 05a06c9bb0..9bc30a5124 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,198 @@ # @objectstack/dogfood +## 0.0.40-rc.1 + +### Patch Changes + +- Updated dependencies [6a67d7a] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [3ec8186] +- Updated dependencies [b1863a5] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [e5e8b10] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [bb1ce2e] +- Updated dependencies [b4be309] +- Updated dependencies [05154a1] +- Updated dependencies [7a55913] +- Updated dependencies [7a55913] +- Updated dependencies [f5ab1c7] +- Updated dependencies [9b6fe7c] +- Updated dependencies [3abd233] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [a946efd] +- Updated dependencies [ea24593] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [7df7c64] +- Updated dependencies [a225ef5] +- Updated dependencies [c8124e5] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [71af9f5] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99ffc04] +- Updated dependencies [7309c81] +- Updated dependencies [a8dcc37] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [4580597] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [efcd68c] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/service-storage@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/plugin-sharing@17.0.0-rc.1 + - @objectstack/plugin-security@17.0.0-rc.1 + - @objectstack/plugin-auth@17.0.0-rc.1 + - @objectstack/plugin-webhooks@17.0.0-rc.1 + - @objectstack/service-messaging@17.0.0-rc.1 + - @objectstack/service-analytics@17.0.0-rc.1 + - @objectstack/plugin-audit@17.0.0-rc.1 + - @objectstack/connector-mcp@17.0.0-rc.1 + - @objectstack/connector-openapi@17.0.0-rc.1 + - @objectstack/connector-rest@17.0.0-rc.1 + - @objectstack/mcp@17.0.0-rc.1 + - @objectstack/verify@17.0.0-rc.1 + - @objectstack/example-showcase@0.3.14-rc.1 + - @objectstack/example-crm@4.0.92-rc.1 + ## 0.0.40-rc.0 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 7fdfd237a9..5f6937ce84 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.40-rc.0", + "version": "0.0.40-rc.1", "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 23b012dd21..ed43c0de81 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,143 @@ # @objectstack/downstream-contract +## 0.0.38-rc.1 + +### Patch Changes + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 0.0.38-rc.0 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 7802949431..439517ccf7 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.38-rc.0", + "version": "0.0.38-rc.1", "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 71035687cb..6ca0462c7f 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/http-conformance +## 0.0.6-rc.1 + +### Patch Changes + +- Updated dependencies [32ccb23] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [3c628ce] +- Updated dependencies [45dc446] +- Updated dependencies [f985b3f] +- Updated dependencies [7777e8f] +- Updated dependencies [7ce02eb] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [857a6cf] +- Updated dependencies [d92c72d] +- Updated dependencies [e4c2dc8] + - @objectstack/core@17.0.0-rc.1 + ## 0.0.6-rc.0 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 3bc06d3a53..32cad0f268 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,9 +1,9 @@ { "name": "@objectstack/http-conformance", - "version": "0.0.6-rc.0", + "version": "0.0.6-rc.1", "private": true, "license": "Apache-2.0", - "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) \u2014 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.", + "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.", "type": "module", "scripts": { "test": "vitest run", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 310d5499cd..5d4fa2f250 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,1403 @@ # @objectstack/rest +## 17.0.0-rc.1 + +### Major Changes + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +### Minor Changes + +- c1dcacd: fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902) + + Record sharing shipped as a data layer with no authorization of its own: every + `/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller + and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke + anyone's share, enumerate who-can-see-what, write self-grants, and define / + evaluate org-wide sharing rules. ADR-0111's P0 rulings land here: + + - **D1/D2** — `ISharingService.canManageShares(object, recordId, context)`: + system, the record's owner, or a holder of Modify All Data (probed via the + new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE, + so every caller is covered; without plugin-security it fails closed to + owner-only. + - **D4** — `revoke` is symmetric with grant, validates the share belongs to the + URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows + (`CONFLICT` — a rule-materialised grant would be resurrected by the next + reconcile). + - **D5** — `listShares` is management-gated (invisible record → `NOT_FOUND`, + visible-but-not-manager → `PERMISSION_DENIED`), and the open + `/data/sys_record_share` read surface is self-scoped: non-admin callers see + only rows naming them as recipient or grantor. + - **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate) + requires the new **`manage_sharing`** capability (D9; seeded into + `admin_full_access`, `manage_platform_settings` honoured as the legacy + equivalent), enforced in `SharingRuleService`. + - **D7** — no inert grants: `recipientType` is narrowed to `user` (the only + type any gate enforces), grants on objects the sharing gates never consult + (public model, no `owner_id`, bypass, `controlled_by_parent`) fail with + `SHARING_NOT_ENABLED` (422), and the manual upsert keys on + `(object, record, recipient, source)` so manual and rule rows coexist. + + **Breaking** for callers that relied on the missing gate: unauthorized share + management now fails with 403/404/409/422 instead of silently succeeding, and + `ISharingService.revoke` gained an optional `scope` parameter. The verb + boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the + separate P1. + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 7d7521f: feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) + + Settles the vocabulary ADR-0112 D6 deferred, per [ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md). + + **`FieldErrorCode` — a closed, lowercase catalog.** 27 members covering what the + six emitters already emit. `FieldErrorSchema.code` tightens from `z.string()` to + this enum, so a validation body's per-field codes are validated for the first time. + `FieldValidationError.code` (objectql) and `FieldCoerceError.code` (rest) stop + being a hand-listed union and a bare `string` respectively and reference the + catalog, so the three cannot drift apart. + + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a + top-level code names the condition the _request_ hit, while a field-level code + names the _constraint_ the value violated — and constraints are declared in the + metadata's own snake_case, so `max_length` the code and `max_length: 50` the + property are the same word on purpose. + + **Zod issue codes no longer reach the wire (wire-visible).** Routes that validate + with Zod passed its vocabulary straight through, so `fields[]` spoke a different + language depending on which route served it, and `too_small` was ambiguous between + a short string, a small number and a short array. `zodIssuesToFields` now maps + using Zod's `origin`/`format`: + + | Was | Now | + | :------------------------------------------------ | :------------------------------------------------- | + | `too_small` | `min_length` / `min_value` / `min_items` | + | `too_big` | `max_length` / `max_value` / `max_items` | + | `invalid_format` | `invalid_email` / `invalid_url` / `invalid_format` | + | `invalid_value` | `invalid_option` | + | `unrecognized_keys` | `unknown_field` | + | `invalid_union`, `invalid_element`, `invalid_key` | `invalid_shape` | + + **A missing required property now reports `required`, not `invalid_type`.** Zod + spells "absent" as a type mismatch against `undefined`, so passing it through made + a form mark a _missing_ input as the wrong _type_. The two are indistinguishable on + the issue alone, so the mapper takes the parsed input as an optional argument and + walks the issue path; a caller that cannot supply it keeps `invalid_type` rather + than guessing. + + **`unknown_param` → `unknown_field`.** `ActionParamIssue.code` references the + catalog instead of its own literal union; the `param` key beside it already says + what was addressed. + + **Not changed:** `EnhancedApiErrorSchema.fieldErrors` keeps its name even though + every producer emits `fields`. Retiring an authorable key needs a tombstone plus a + migration (ADR-0104's contract guard), so it lands on its own — the property now + carries a banner saying which name the wire uses. + +- 789ad63: fix(spec,rest): the batch-size cap is enforced now, and each bulk endpoint has one Zod source (#3939) + + `max 200` was declared in four places and enforced in one. + + `batch.zod.ts` put `.min(1).max(200)` on `BatchUpdateRequestSchema`, + `UpdateManyRequestSchema` and `DeleteManyRequestSchema`, and the docs repeated + it — but no per-object bulk route validated against those schemas, so + `createMany` / `updateMany` / `deleteMany` / `/data/:object/batch` all accepted + an unbounded list. The only route that capped anything was the cross-object + `/batch`, and it checked the _configured_ `maxBatchSize` rather than the + hardcoded 200 — so even the one enforcement point disagreed with the schema. + + That stopped being cosmetic with #3897, which made `deleteMany` delete per id by + primary key (so `deleteBehavior` cascades run and every row gets its own + result). A 10k-id body is now 10k sequential engine round-trips inside a single + request, where before it was one statement that mostly failed anyway. + + **The cap moved to the routes, and the schemas gave it up.** Batch size is + deployment policy — `RestServerConfig.batch.maxBatchSize`, 1..1000, default 200 + — so a hardcoded bound in the spec could only ever be a second, wrong answer + (a deployment raising the limit to 500 would still have been refused at 200). + All five bulk routes now call one `enforceBatchSize` helper with the configured + value and answer with one envelope: + + ```json + { + "error": "Batch too large: 500 records (max 200)", + "code": "BATCH_TOO_LARGE", + "count": 500, + "max": 200, + "object": "account" + } + ``` + + The cross-object route is included: it used to answer with a bare `error` string + and no `code` for a client to key on. + + **One Zod source per bulk endpoint (Prime Directive #7).** Each of these + endpoints had _two_ schemas, and they had already drifted into disagreeing about + more than counts: `UpdateManyRequestSchema` described its rows with + `BatchRecordSchema`, whose `id` and `data` are optional because the generic + `/batch` route serves create (no id) and delete (no data) through the same + shape — so the declared contract accepted `{}` rows that `updateManyData`, which + reads `record.id` and `record.data` unconditionally, could never process. The + enforced shape lived in the _other_ copy, in `protocol.zod.ts`. + + The wire body is now the single source (`UpdateManyRequestSchema` / + `DeleteManyRequestSchema`, with the new `UpdateManyRecordSchema` for a row), and + the protocol schemas are that plus the `object` the route takes from the URL + path (#3933) — `UpdateManyRequestSchema.extend({ object })`. The derivation runs + that direction because `protocol.zod` already imports `batch.zod`; the reverse + would be a cycle. + + **Behaviour changes.** + + - A bulk request over the configured cap is `400 BATCH_TOO_LARGE` instead of + being executed. Deployments that were quietly relying on unbounded batches + should raise `batch.maxBatchSize` (up to 1000) rather than discover the cap in + production. + - `.min(1)` is gone with `.max(200)`: an empty batch is a no-op returning + `total: 0`, which is what these routes already did, rather than a validation + error the schema claimed but nothing raised. + - `UpdateManyRequest` now types (and validates) `records` as + `{ id: string; data: Record }[]`. Callers already had to send + that — the route has validated the strict shape since #3933 — but the declared + type was looser. + - New export: `UpdateManyRecordSchema` / `UpdateManyRecord`. + +- fccec22: fix(rest): bulk writes bind to the object in the path, not the one in the body (#3933) + + `POST /data/:object/updateMany` spread the request body over the value it had + just taken from the URL: + + ```js + const result = await p.updateManyData!({ + object: req.params.object, // trusted, written first + ...req.body, // …and spread over it + ... + }); + ``` + + The gate on the line above reads the PATH object — `enforceApiAccess` starts + with `const objectName = req?.params?.object` — so `enable.apiEnabled` / + `enable.apiMethods` (ADR-0049 / #1889) was enforced on the object in the URL + while the object named in the body got written. Measured on a stock CRM dev + deployment: `POST /data/crm_account/updateMany` with + `{"object":"crm_contact", "records":[…]}` returned `succeeded: 1` and changed + the `crm_contact` row. Point the URL at any exposed object, name a hidden one in + the body, and the gate clears the wrong object every time. + + This is not a row-authorization bypass — the engine middleware still evaluates + RLS/FLS against the object actually written, and `assertObjectRegistered` (#3770) + still resolves it. What it defeats is the object-level exposure policy, the layer + ADR-0049 exists to make enforceable rather than advisory. + + The path object is now written LAST, after the body, so the object the gate + cleared is the object that gets written — a property of the code rather than of + the caller declining to send that key. The body is parsed against + `UpdateManyDataRequestSchema` first, which (Zod strips unknown keys) also stops a + body `context` from becoming the execution context on a deployment where none + resolves — `requireAuth: false` plus an anonymous caller, the one case where the + trailing `...(context ? { context } : {})` has nothing to overwrite it with. + + `deleteMany` gets the same ordering: #3897 moved it behind a schema parse, but + fed that parse `{ object: req.params.object, ...req.body }` — still body-wins. + `createMany` (`records: req.body || []`) and `batch` (`request: req.body`) never + splatted the body at the top level and are unaffected. + + **Behaviour change.** A malformed `updateMany` body is now `400 +VALIDATION_FAILED` naming the offending path, instead of reaching the protocol + and failing further in. A body `object` key is ignored rather than honoured. + +- f4d7f1d: fix(metadata-protocol,rest): the id list is the only thing deleteMany can select on (#3897) + + `deleteManyData` built the predicate its endpoint is named after and then spread + the caller's `options` **over** it: + + ```js + return this.engine.delete(request.object, { + where: { id: { $in: request.ids } }, + ...request.options, // ← lands after `where`, so it can replace it + }); + ``` + + `request.options` is caller-supplied — `POST /data/:object/deleteMany` splatted + the whole request body into the protocol request (`{ object, ...req.body }`) — + so one body key rewrote the operation: + + ```json + { "ids": ["a"], "options": { "multi": true, "where": {} } } + ``` + + reached `engine.delete` as an unscoped bulk delete. The engine's write + middleware still composes RLS/sharing predicates onto the AST, so the blast + radius is not automatically the whole table: it is **everything the caller is + allowed to delete**. For an ordinary user with delete permission that is the + difference between the 3 records they asked for and every record they can see; + measured on a stock CRM dev deployment, that payload against one id removed all + 8 rows in the object and returned the raw driver count (`8`). The same spread + also accepted `context`, i.e. a forged principal wherever the route is reachable + without auth. + + **The id set is now authoritative, structurally.** The engine options are built + from the validated id list and nothing else — caller `options` is a + `BatchOptions` bag (`atomic` / `returnRecords` / `continueOnError` / + `validateOnly`) that carries nothing `engine.delete` consumes, so merging it + could only ever smuggle in engine keys. Ids must be scalars, so an operator + object (`{"ids":[{"$ne":null}]}`) cannot reach `where.id` either; a malformed + list is a `400 VALIDATION_FAILED` instead of a wider delete. The REST route + parses the body against `DeleteManyDataRequestSchema` first, one hop earlier — + Zod object schemas strip unknown keys, so `options.where`, top-level `where` and + a body `context` no longer survive the ingress at all. + + **The endpoint also works now.** `deleteManyData` never set `multi`, so a + correctly-formed `{"ids":[…]}` hit the engine's + `'Delete requires an ID or options.multi=true'` throw — only the requests that + triggered the override above ever completed. Deletes now go one id at a time by + primary key, the same shape `batchData`'s `delete` case uses, which closes two + gaps behind that: the bulk branch skips `cascadeDeleteRelations`, so + `deleteBehavior` (`cascade` / `set_null` / `restrict`) was not honoured for the + rows it removed; and the declared `BatchUpdateResponse` contract (per-record + `results`, `atomic`, `continueOnError`) was unimplementable from a bulk row + count. Both are delivered rather than declared. + + **Behaviour change.** The endpoint returns a `BatchUpdateResponse` + (`{ success, operation, total, succeeded, failed, results }`) where it + previously returned the driver's raw delete count — on the paths where it + returned anything at all. The caller's execution context is threaded to every + delete, so RLS/FLS now run under the caller here as they do on the single-record + route. + +- 507b92a: fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957) + + The write path built every built-in validation message by concatenating the **API + field name** into a **hardcoded English** template. Those strings are what the + Console toast, the CSV-import row report, the CLI and any custom client display + verbatim, so a Chinese-locale user importing a bad row read: + + ``` + 第 1 行:penalty_amount must be ≥ 0 + ``` + + …for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The + form layer localized the _same_ constraint correctly (the browser's native + `min`), so the language flipped depending on which layer caught the value. + + **Three things changed.** + + 1. **The message is rendered in the caller's locale** from a built-in catalog + (`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`, + `zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles. + The locale comes from `ExecutionContext.locale`, whose contract already read + "Drives message catalogs"; this is the consumer that makes that true. Both + HTTP entries (REST server, runtime dispatcher) now resolve it from the + request's `Accept-Language` / `?locale` first, falling back to the workspace + `localization.locale` — so a rejection message and the field labels around it + can no longer disagree. + + 2. **The field is named by its label, never the API name**: translation bundle + (`objects..fields..label`) → declared `label` → API name as the last + resort. `FieldValidationError.field` still carries the API name so a form can + focus the right input. + + 3. **The constraint is exposed as data**, so a client can format its own text + instead of parsing the sentence: + `{ field, code, message, label, constraint: { min: 0 } }`. This rides + ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema` + (`constraint` tightens from `unknown` to `Record`) rather + than adding a parallel payload — `label` is the only new field. The bag + carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the + message templates interpolate from exactly those keys. + + Covered end-to-end, not only in the validator: single and batch insert, + single-id and multi-row update, ADR-0113's clear-out rejection, the object-level + rule evaluator's own built-in messages (`requiredWhen`, per-option gating, + state-machine fallbacks), and the importer's cell-coercion, required pre-check + and #3956 bound pre-check messages — all of which land in the same row report. + + **What this changes for consumers.** + + - `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to + match on. Message keys are finer-grained than codes — `invalid_datetime`, + `invalid_option_value`, `required_cleared` are rendering detail and never reach + the wire — so localization never splits the client-facing vocabulary. + - `message` **text changes**: it is localized, and it names the field by label + even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything + asserting on the old English string should match `code` (and now + `constraint`) instead. + - An author-written validation-rule `message` is never touched — it is already + in the language its author chose. + - A deployment can override any built-in message with a `translation` item + defining `validation.field.` (e.g. + `validation.field.min_value: '{{label}}不得小于 {{min}} 元'`). + - The importer's reference-failure message no longer names the target object's + API name (`no sys_user matches "…"`): naming internal identifiers is the + defect being fixed, and the column plus the offending value are what an + importer can act on. + +- be7945a: feat(rest): `audience: 'public'` publishes a book anonymously on a secure-by-default deployment (#3963) + + `book.audience: 'public'` was a declared per-book capability that in practice + required the deployment to open its **entire** data plane. The `/meta` umbrella + gate refused every anonymous caller unless `api.requireAuth` was `false`, so a + `public` book was only ever reachable inside a globally-public deployment — the + audience model was _re-narrowing_ what that flag had already opened, not granting + anything of its own. ADR-0046 §6.7 recorded exactly that as ground truth ("the + gate is the optional global `requireAuth` … not the handler"). + + The exemption is now derived from the declaration, the same shape ADR-0056 + Option A chose for public form submission (`publicFormGrant`): the umbrella gate + admits an anonymous **GET** of the book/doc read surface, and the §6.7 audience + gate inside the handler is what authorizes it. + + Narrow in three independent ways: + + 1. **Only when no execution context resolved.** An authenticated caller still + goes through `enforceAuth` unchanged, so the ADR-0069 auth-policy gate + (expired password, enforced MFA) keeps governing a gated session's book reads. + 2. **Only GET, only book/doc.** `GET /meta/:type`, `GET /meta/:type/:name` (type + `book` or `doc`, either spelling — #3984) and `GET /meta/book/:name/tree`. + Every other type stays 401 for anonymous, writes stay 401, and `GET /meta` + itself stays 401. The predicate keys on the REGISTERED route path plus the + normalized `:type`, so a route added later cannot fall into it by accident. + 3. **Reachability, not authorization.** `audienceAllows` admits `'public'` only; + `org` and `{ permissionSet }` books require `caller.authenticated` and + unresolvable holdings fail closed, so an anonymous read of a gated book is + still `401`. + + A deployment can now publish a public manual with `requireAuth: true` — which is + the prerequisite for retiring that flag entirely (#3963 step 2). ADR-0046 §6.7 + carries an amendment recording the new gate; its SEO and tenant-from-host + reasoning is unchanged, having never depended on the flag. + +- a1b61e0: Request bodies are now checked against the schemas the API catalog declares for them (#3899, the request-side dual of #3877). + + **Routes that now answer `400 VALIDATION_FAILED` + `fields[]` for a body violating their declared `requestSchema`** (previously the body was consumed raw, and a malformed one silently executed different semantics): + + - `POST /data/:object/query` — body must be a QueryAST (`FindDataRequestSchema`); a garbage body used to degrade into an unfiltered full read. The path `object` is now pinned into the forwarded query (a body `object` can no longer contradict the path). + - `POST /data/:object` / `PATCH /data/:object/:id` — body must be a record object (`CreateDataRequestSchema` / `UpdateDataRequestSchema`). + - `POST /data/:object/batch` — body must be a `BatchUpdateRequestSchema` (`operation` + `records[]`). + - `POST /data/:object/createMany` — body must be a bare JSON array of records (`CreateManyDataRequestSchema`); `{ records: [...] }` (updateMany's envelope) is rejected with a pointer. + - `POST /notifications/read` — body must be `{ ids: string[] }` (`MarkNotificationsReadRequestSchema`); a misnamed key used to become `markRead(userId, [])` — a 200 no-op that never cleared the badge. + + **Dispatcher automation routes now validate their bodies** (no catalog schema; hand-written guards): + + - `POST /automation` and `PUT /automation/:name` require a flow-definition object, and POST requires a non-empty `name` — a mistyped `name` used to register the flow under the key `undefined` and echo 200. + - `POST /automation/:name/toggle` is strictly `{ enabled?: boolean }` — `{"enable": false}` (one letter off) used to ENABLE the flow and answer 200 `{enabled: true}`; it is now a 400 naming the offending key. An empty body still means enable. + + **`QuerySchema` now declares the search contract ADR-0061 actually serves** (additive): `search` accepts the canonical bare query string as well as the structured `FullTextSearch` form, and the server-validated `searchFields` narrowing is formally declared. Previously the schema declared only the object form while every surface (and the ADR's own conformance proof) sent the string — drift that surfaced the moment request bodies started being validated. + + **Catalog corrections in `@objectstack/spec` (`plugin-rest-api.zod.ts`)** — documentation-only tables: + + - `DEFAULT_NOTIFICATION_ROUTES` drops the four device/preferences endpoints — those server routes were removed in #3612 (never built), yet the table kept declaring them, `requestSchema` and all. + - `DEFAULT_AUTOMATION_ROUTES`' trigger endpoint path is corrected `/trigger` → `/trigger/:name` (the mounted path; the flow name rides the path) and its `AutomationTriggerRequestSchema` declaration is removed — that schema never described this route's wire shape. + - `DEFAULT_DATA_CRUD_ROUTES` gains the `POST /:object/query` entry (mounted since forever, previously undeclared), repoints create/update to the schemas the routes actually validate (`CreateDataRequestSchema` / `UpdateDataRequestSchema` — the old `CreateRequestSchema`/`UpdateRequestSchema` names described a `{ data }` envelope the wire never had), and drops `requestSchema` from GET/DELETE entries (path/query-bound inputs; nothing can violate them as a body). + - New gates: catalog `requestSchema`/`responseSchema` strings must resolve to real exported Zod schemas, `requestSchema` may only sit on body-carrying methods, and every declared `requestSchema` on a mounted route has a violating-body → 400 conformance case (`packages/rest` + `packages/runtime` request-schema-gate suites). + + Migration: clients that already send the documented shapes are unaffected. If you relied on a malformed body being silently accepted (e.g. posting `{ records: [...] }` to `createMany`, a non-boolean `enabled` to toggle, or an off-schema analytics/query body), fix the request to the declared shape — the 400's `fields[]` names each offending key. + +### Patch Changes + +- 8d895ff: feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) + + Two more hand-copied lists retired the same way, each replaced by one spec + export and derivation at every consumer. + + **`AUDIT_PROVENANCE_FIELDS`** (`@objectstack/spec/data`, with the + `AuditProvenanceField` type) — the four columns `applySystemFields` injects on + every audit-tracked object: `created_at`, `created_by`, `updated_at`, + `updated_by`. That four-name list existed in at least four copies across two + repos: the registry's injection if-chain, the rule-validator's `preserveAudit` + allowlist ("Kept in sync with the registry's auto-injected audit fields" — by + nothing), and two objectui render surfaces. Now: + + - the registry's injection is table-driven, keyed by the tuple with a + `satisfies Record` clause — a name added to the spec + without a column definition (or vice versa) is a compile error, the + `APPROVER_VALUE_BINDINGS` discipline; + - the rule-validator's `AUDIT_TIMELINE_FIELDS` derives from the same tuple; + - `FIELD_GROUP_SYSTEM_FIELDS`' audit prefix derives from it too — one + declaration even inside the file that hosts both; + - objectui's `AUDIT_FIELD_BY_ROLE` already pins itself by subset assertion and + can import the tuple directly once this release is published. + + Injection behaviour is byte-identical — a conformance test pins every injected + column's shape against the pre-refactor definitions. + + **`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` / + `IMPORT_REFERENCE_TYPES`** (`@objectstack/spec/data`) — the `/import` coercion + vocabulary #4173 asked for. The server's `import-coerce.ts` now derives its + `BOOL_TRUE` / `BOOL_FALSE` / `REFERENCE_TYPES` from these instead of owning + them privately, and objectui's Import Wizard preview — which re-checks the same + contract client-side so a cell is flagged red exactly when the server would + reject it — can retire its pinned-inventory mirror once this release is + published (the retirement path is written in that file's own header). + `IMPORT_REFERENCE_TYPES` ships with the legacy `'reference'` spelling included, + retiring the `+ 'reference'` literal both ends carried separately. The tables' + own discipline is tested: sets disjoint, every token pre-normalized + (lower-case, trimmed), and the Chinese / check-mark spreadsheet-reality tokens + pinned by name. + + No behaviour change anywhere: every derived value is byte-identical to the + literal it replaces. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- fae74b5: fix(rest): give the bare 501 error exits a machine `code` (#4067) + + Most REST error exits already carry a typed `code` (`VALIDATION_FAILED`, + `BATCH_NOT_ATOMIC`, `BATCH_TOO_LARGE`, `PERMISSION_DENIED`), and the clone / + search 501s already answer `{ error, code: 'NOT_IMPLEMENTED' }`. Four 501 exits + still returned a bare `{ error: '' }` with no code, so a client could + only key on the prose: + + - the cross-object transactional batch route (`POST {basePath}/batch`) when the + runtime has no `transaction()` — the last untyped exit on that route, whose + siblings (`BATCH_NOT_ATOMIC`, `VALIDATION_FAILED`, the `enforceBatchSize` + `BATCH_TOO_LARGE`) were already typed by the #3897 / #3933 / #3939 line; + - the two `saveMetaItem`-unsupported exits; + - the UI-view-resolution-unsupported exit. + + Each now carries `code: 'NOT_IMPLEMENTED'`, matching the clone / search 501s. + Additive only — the `error` message is unchanged and no status changes — so + existing clients are unaffected; new ones can branch on the code. + +- 366105c: fix(service-datasource,rest): the last three uncovered datasource routes answer their registered refusal code (#4264) + + #4249 (fixed in #4263) gave the rest surface's two introspection routes a + failure contract; this closes the same gap on the three sibling routes it left + uncovered. Each had no `catch` around its service call, so a service throw was + swallowed by the adapter and surfaced as the pre-#3675 non-envelope + `500 { error: 'No response from handler' }` — no `success` flag, no + `error.message`, no code to switch on, real cause lost. + + Wire-visible changes — each route now answers `400` in the declared envelope, + under the refusal code registered (ADR-0112) for the service it dispatches to, + with the service's own message at `error.message`: + + - `GET /api/v1/datasources` (`listDatasources` throw) → + `400 DATASOURCE_ADMIN_ERROR` — matching its eight siblings in + `service-datasource/admin-routes.ts`, which already answer their catches this + way. + - `POST /api/v1/datasources/:name/external/refresh-catalog` (`refreshCatalog` + throw) and `POST /api/v1/datasources/:name/external/validate` (`validateAll` + throw) → `400 EXTERNAL_DATASOURCE_ERROR` — the same code #4249 gave the two + introspection routes one block above them. + + The issue left the code choice open (`INTERNAL_ERROR` was the alternative); + the registered per-service codes win on consistency: every other catch in both + modules — including pure reads — already answers 400 with the service-attributed + code, and `refreshCatalog`'s dominant throw class (unknown datasource, + unreachable remote, no such schema) is the one #4249 already adjudicated as a + 400 refusal on `listRemoteTables`. A 500 here would fork the failure contract + within a module — the drift #4249 removed. + + No new codes: both were registered in the error-code ledger by #4263. The + envelope-conformance suites and the `REFUSALS` pin table gain one row per + route. + +- f0d6594: fix(rest): `GET /data/:object/export` honours a `search` term + + The streaming export route accepted `filter` and `orderby` but had no way to + carry the term a user had typed into the list's search box. So exporting after + a search downloaded the **unsearched superset** — more rows than the screen + showed, in a file that looks authoritative, with nothing indicating the + difference. The route's own comment claimed the opposite: that it "mirrors the + active view's filter + sort so the exported file matches what the user sees". + + Same family as a dropped filter (objectstack#3948, objectstack#4181): a + plausible answer that is quietly broader than the one asked for. + + Two new query params, both matching the list endpoint's semantics: + + - `search=` — folded into `findData` as `$search`, so it **composes** + with `filter` (`{ $and: [filter, search] }`) rather than replacing it. Empty + or whitespace-only terms are ignored rather than applied as a blank predicate. + - `searchFields=a,b` — the ADR-0061 override for which fields the term scans. + Only meaningful alongside `search`, and intersected with the object's allowed + searchable set by the engine, exactly as on the list endpoint. + + Unknown query params on this route were already ignored, so a client that sends + `search` to an older server gets today's behaviour rather than an error. + + Covered by `export-integration.test.ts` against the real engine + protocol: the + composition case is built so each half alone returns a different non-empty + result and only "both applied" returns none. Reverting the route change fails 4 + of the tests. The file's in-memory driver also learned `$or` / `$contains` — + without them a search predicate is a silent no-op and an "it filtered" + assertion would pass for the wrong reason. + +- bcf1112: fix(service-datasource,rest)!: external-datasource refusals answer their own error code (#4249) + + #4225 / #4234 fixed the 503 `message` on the three routes in + `service-datasource/admin-routes.ts` that dispatch to `external-datasource` + rather than `datasource-admin`. The identical mis-attribution survived one field + over, on the 400 path — and machine-readably: one shared `badRequest` helper + hard-coded `DATASOURCE_ADMIN_ERROR`, which the ADR-0112 ledger defines as a + refusal _from the datasource-admin service_. So a `no such schema` raised by the + external-datasource introspector was reported as datasource-admin's, and where + #4225 misled a human reading prose, this misrouted a client switching on + `error.code`. + + `EXTERNAL_DATASOURCE_ERROR` is now registered in the error-code ledger — under + `@objectstack/service-datasource` and `@objectstack/rest`, the two packages that + emit it; per the ledger's own rule the per-package rows are provenance, not + identity — and `badRequest` takes the same `ServiceName` the route passed to + `resolve` (#4234), so the code, like the 503 message, comes from the service the + route actually dispatches to. + + Wire-visible changes: + + - **The three external-datasource routes' 400 `error.code`** — + `GET /datasources/:name/remote-tables`, `POST /datasources/:name/test`, + `POST /datasources/:name/object-draft` — is now `EXTERNAL_DATASOURCE_ERROR` + (was `DATASOURCE_ADMIN_ERROR`). Status, envelope, and `error.message` are + unchanged, as is everything on the six datasource-admin routes. No consumer + branches on the old code (grepped both repos, all the ADR-0112 sweep forms). + - **The rest surface's two introspection routes now have a failure contract at + all.** `GET /datasources/:name/external/tables` and + `POST /datasources/:name/external/tables/:remote/draft` carried no + `try`/`catch`, so the very same service operations that answer 400 through + the admin surface surfaced here as the adapter's non-envelope + `500 { error: 'No response from handler' }`. They now answer + `400 EXTERNAL_DATASOURCE_ERROR` in the declared envelope — one operation, one + failure contract, on both paths. (`EXTERNAL_IMPORT_ERROR` on the import route + is unchanged: a refused import is a different act from a failed + introspection, and its name says so.) + + Why a new registered code rather than reusing one: ADR-0112's ledger asks + _generic_ conditions to reuse the standard catalog — that argument carried + #4225's 503, where `SERVICE_UNAVAILABLE` is correct for all nine routes and only + the free-text `message` named the service. A refusal specific to one service is + exactly what registered extension codes are for, and the closed `ErrorCode` + union means correcting the attribution had to be a ledger edit. Widening + `EXTERNAL_IMPORT_ERROR` to cover introspection was rejected because these are + not imports; leaving the throws uncaught was rejected because the adapter's 500 + is not the declared envelope. + + The conformance rows that pinned the drift move with it, and each surface now + pins the refusal code per route the way #4234 pinned the 503 message per route. + + Pre-existing, like #4225: #3843 carried every code string over verbatim. + +- 99b4392: Advertise `mcp` in `/discovery` only when it is actually serveable (#4024). + + Both discovery producers gated the `/mcp` route on `isMcpServerEnabled()` alone. + The stated justification was a lockstep — `os serve` auto-loads plugin-mcp from + the same flag, so on that path advertised did imply mounted. But the lockstep is + a property of the CLI, not of the dispatcher: `@objectstack/rest` has no + `@objectstack/mcp` dependency, mounts no `/mcp` route and performs no auto-load, + so a host that embedded it without plugin-mcp advertised `/mcp` in `/discovery` + and then answered 501 on it — the `declared ≠ enforced` failure #3369 forbids, + and a broken contract for third-party clients that read `/discovery` to decide + what exists. + + Both producers now require the flag AND a serveable MCP service. The runtime + dispatcher gates on the handler's own predicate (`typeof +mcp.handleHttpRequest === 'function'`), so a wrong-shaped service can't + over-promise either. `@objectstack/rest` probes via the per-request kernel or the + single-env `serviceExistsProvider`; when it genuinely cannot probe it keeps the + prior flag-only answer rather than hiding a working endpoint (fail-open, + ADR-0057 D10). The `os serve` / `os dev` path is unchanged — it loads the plugin, + so the service resolves and `/mcp` is still advertised. + + Also exercises the `mcp: false` seam in `route-parity.integration.test.ts`, which + had existed unused since the file was written: `bootServe()` was only ever called + with no args or `{ notification: false }`. The one capability whose advertisement + was not service-presence gated was also the one whose absence was never tested. + +- 495019b: fix(rest): the /meta per-type gates are enforced on both spellings of the type segment (#3984) + + Every per-type filter on `GET /meta/:type` and `GET /meta/:type/:name` compared + `req.params.type` to a literal SINGULAR name, while the protocol's `getMetaItems` + normalizes singular↔plural and serves either. Prime Directive #3 makes plural the + canonical REST spelling, so the form a client is most likely to use — + `/api/v1/meta/books` — reached the handler with every gate skipped. + + Three of those gates are authorization: + + - **ADR-0046 §6.7 book / doc audience** (three sites: the list, the single-item + read, and the doc effective-audience union). `GET /meta/books` returned a + `{ permissionSet }`-gated book — an _Admin Guide_ — to a caller who does not + hold the set, and `GET /meta/books/admin_guide` answered `200` where the + singular spelling answers `401`. On a publicly-served deployment the same skip + handed an `org` book to an anonymous reader. + - **App RBAC filter** — hides privileged apps (Studio, Setup) and gated nav + entries from callers without the grants. `GET /meta/apps` skipped it. + - **Dashboard `requiresService` gate** (ADR-0057 D10). `GET /meta/dashboards` + skipped it. + + The remaining spelling-sensitive branches are behavioural rather than + authorization — doc i18n locale collapse, and the list-response `content` strip — + and were inconsistent between the two spellings for the same reason. + + Each handler now normalizes the type ONCE (`RestServer.metaTypeSingular`, backed + by the same `PLURAL_TO_SINGULAR` table the protocol uses) and every gate keys on + that value, so the two spellings of one route can no longer diverge. Found while + scoping #3963. + +- 20bc1ec: fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786) + + Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout + that names keys of a Zod schema it never imports: two descriptions of one key + set, a comment asking the next author to keep them in step, and nothing that + fails when they don't. #3786 asked for a sweep of that shape across the repo. + **Four of the seventeen forms had already drifted, every one of them silently.** + + The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not + `.strict()`, so a key the schema does not declare parses clean and is stripped + on the way to storage — the same ADR-0104 failure class the `field.zod.ts` + prune tombstone already describes in prose. An admin toggled a switch in + Studio, got no error, and the value never landed. + + **What was broken, from an author's seat:** + + - **Object → Capabilities.** The block bound to `capabilities`; the + `ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable, + API enabled, Files, Feeds, Activities, Clone) saved nothing. + - **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has + never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`, + `Placeholder`, `Validation`/`Error message` and `Starting number` were + controls with no storage behind them at all; the rest named keys the schema + had **renamed** and the form never followed: + `referenceFilter` → `lookupFilters`, `cascadeDelete` → `deleteBehavior` + (a three-way enum, not a boolean), `formula` → `expression`, + `displayFormat` → `autonumberFormat`, and the flat `summaryType` / + `summaryField` pair → the single `summaryOperations` object, which also + restores the `object` key the flat pair had no slot for. Roll-ups authored in + that grid saved nothing. + - **Report → Advanced.** `aria` and `performance` were pruned from + `ReportSchema` by #3496; the form kept rendering both. + - **Hook / Action → Body.** `memoryMb` was unauthorable — named in + `hook.form.ts`'s own doc comment, absent from the list beneath it. + - **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's + default sort order could not be set in Studio at all. + + **No authored metadata changes and nothing you can write is removed.** These + were UI controls that never persisted; every corrected key is one `FieldSchema` + / `ObjectSchema` already accepted. Metadata authored in YAML/TS was always + validated against the real schema and is unaffected. If you had been filling + those Studio controls expecting them to stick, they now either work (the + renamed five) or are gone rather than lying to you. + + The metadata-form translation bundles are derived from the registry, so all + four locales are regenerated. Worth naming what they contained: translated + labels, in four languages, for switches that saved nothing — the drift had + propagated into a generated artifact and been dutifully translated there. + + **The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every + registered form and reconciles it against `getMetadataTypeSchema()`. The two + directions are deliberately asymmetric: **form-only** (a control whose value is + discarded) is always a defect and cannot be excused, because no design wants + one; **zod-only** is ledgerable with a reason, for a deprecated key held back + from new authoring or a curated quick-add subset that defers to a fuller + editor. Ledger entries are checked for non-vacuity and for still resolving on + both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding + a stripped key, dropping a covered key, and offering a ledgered omission each + turn the gate red. + + **New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the + set of metadata types whose labels `translateMetadataDocument` localizes, + derived from its dispatch table rather than restated. `@objectstack/rest` had + been carrying a hand-copied literal set under a "keep in sync with the type + dispatch" comment; it now reads this instead. Registering a translator in spec + reaches the REST boundary with nothing else to remember — the second list is + deleted rather than checked, which is the better half of derive-or-gate. + + Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored + `ToolCategorySchema` in `ai/tool.zod` and told the next author to update both + sides — but #3896 deleted `ToolCategorySchema` along with the inert + `tool.category` key it typed. The instruction had been pointing at a source + that no longer exists. The enum is canonical now and says so. + +- 6c87cc9: fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181) + + `GET /api/v1/data/:object?filter={status:done` — one missing quote — answered + `200` with the **unfiltered** page. The JSON-parse tolerance + (`catch { /* keep as-is */ }`) left the raw string on `where`, a shape no + driver consumes, so the filter was dropped whole and the response was + byte-for-byte a successful unfiltered query. The worst failure direction in + this family: #4134 returned nothing, #4164 dropped one predicate, this + returned everything. + + The sibling `GET /data/:object/export` route had rejected the same input since + it was written — the list path was the outlier. That guard now lives in the + shared normalizer, so `GET /data/:object`, `POST /data/:object/query` and the + runtime dispatcher all give one answer: + + - Unparseable JSON → `400 INVALID_FILTER`, naming the parameter and stating the + filter was not applied. + - Parses but is not a filter (`?filter=5`, `?filter="done"`, `?filter=null`) → + same rejection; usable JSON is not a usable filter. + - Blank `?filter=` → treated as absent, as before. No error. + - `filter` / `filters` / `$filter` / `where` are four spellings of ONE slot. + Sending two with **different** values used to run one and discard the rest + silently; it is now `400 INVALID_REQUEST` (each value is a valid filter — the + _request_ is ambiguous, so it does not share the malformed-filter code). + Redundant identical spellings pass. + - `orderby` on the export route gets the same treatment — a sort that cannot be + parsed is refused rather than dropped (lower stakes than a filter: the row set + is unchanged, but a caller taking "latest N" got an arbitrary N). + + **One wire code for one condition.** #4121 landed `400 INVALID_FILTER` for + malformed filter _arrays_ on this same code path while this fix was in flight; + the non-array rejections above use that code too, so a caller asking "did my + filter run?" never has to know which branch caught it. The export route's + filter guard moves from `INVALID_REQUEST` to `INVALID_FILTER` to match — a wire + change on an existing route, and the reason it is worth making is that a client + otherwise has to handle two codes for one condition depending on which URL it + called. The route's `orderby` guard keeps `INVALID_REQUEST` (it is not a + filter). + + **What changes for callers:** requests carrying a malformed filter now fail + loudly instead of receiving every record. Every valid filter shape — JSON + string, live object, `FilterCondition` AST array, and all four alias spellings + used alone — is unaffected. + +- af2a095: fix(data): `searchFields` / `groupBy` / `aggregations` naming a field that does not exist are rejected, not silently degraded (#4254) + + #4226 closed `sort` / `select` / `expand`; with the filter axis (#4134 / #4164 / + #4181 / #4121) that made four field-naming read axes that either apply or fail. + The same machine kept leaking on the remaining three, and each failure corrupted + something the closed axes never touched: + + ``` + search=alpha&searchFields=no_such -> 200 MORE rows than the narrowing allowed + groupBy=[no_such] -> 200 [{no_such: null, n: }] N groups collapsed into 1 + sum(no_such) -> 200 0 — indistinguishable from a real zero + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `POST /data/:object/query`, the export route and the runtime dispatcher give + one answer instead of four. + + - **`searchFields` → `400 INVALID_FIELD`.** The `select` failure with the sign + flipped outward: the engine dropped unknown names and, when that emptied the + override, fell back to the FULL searchable set — so a parameter that exists + only to narrow a search widened it, and it changed which ROWS came back, not + just which columns. Its only in-framework caller is `GET /data/:object/export` + — the route whose `search` support just shipped so exports would stop + downloading "the unsearched superset … in a file that looks authoritative"; + a typo'd `searchFields` did exactly that, one parameter over. Three causes, + three messages, because the fixes differ (the split #4226 drew on expand): a + name that is no field is a request typo; a REAL field outside the searchable + set needs the object changed (its message names the declared + `searchableFields` or the auto-default's type rule, whichever applies); and + a `searchableFields` entry that names no field is a STALE DECLARATION — a + bug on the object, called out as such because clients (objectui's list + search) echo the declaration verbatim. The allowed set is resolved by the + same `@objectstack/spec/data` function the engine's search expansion + consumes (`resolveSearchFieldResolution`, moved from objectql), so the gate + cannot drift from what search actually scans. + - **`groupBy` → `400 INVALID_FIELD`.** The in-memory aggregation path projects + an unknown column as `null` for every row, so all rows landed in ONE bucket + whose count is the true row count — structurally perfect, identical to "this + column really holds a single value". A chart draws one bar; nothing says the + grouping never ran. Native SQL aggregation errors on the same input, so which + backend a deployment sits on decided the answer — the "two routes, opposite + answers" split, one axis over. + - **`aggregations` → `400 INVALID_FIELD`.** `sum()` folded a column of + `undefined` to `0` — the exact number an empty quarter produces, in reports + whose whole job is to be believed (`avg`/`min`/`max` answered `null` the same + way). `count` with no `field` (or the `'*'` sentinel) is the one legitimate + field-less form and passes. + - **Unreadable SHAPES on the aggregation axes → `400 INVALID_QUERY`** — the + standard-catalog code that had no emitter since it was written, like + `INVALID_SORT` before #4226. A string `groupBy`, an entry naming no field, a + function or `dateGranularity` outside the spec enums, a missing `alias`: each + slipped past the `Array.isArray` routing guard (rows returned UNGROUPED) or + computed a silent placeholder (`null` results, a column keyed `"undefined"`, + one bucket per raw value under an unknown granularity). + + Tiering is unchanged from #4226: registry + field map present → authoritative; + no registry / no field map / legacy array field map → the NAME gates skip (shape + gates still apply — they need no schema). The engine's own tolerance is + untouched: internal callers reaching `engine.find()` / `engine.aggregate()` + directly are unaffected. `@objectstack/rest` also stops logging + `INVALID_FILTER` / `INVALID_SORT` / `INVALID_QUERY` rejections as + "[REST] Unhandled error" — they are client mistakes the response already + explains, as `INVALID_FIELD` always was. + + Requests that name real fields are unaffected. + +- dd5daac: fix(data): reject unknown list query parameters instead of reading them as zero-matching field filters (#4134) + + `GET /api/v1/data/:object` reads any parameter it does not reserve as a + field-level equality filter — that is what makes `?status=done` shorthand for + `?filter={"status":"done"}`. When the name matched **no** field the resulting + predicate could only ever match nothing, so `?pageSize=5` on a 10-row object + returned `200` + `total: 0`: structurally valid, and indistinguishable from + "this object is empty". The write path already rejected the same unknown name + loudly (`400 INVALID_FIELD`), so one piece of knowledge — does this field + exist — was enforced on write and silently zeroed on read. + + The read path now answers the same way, in the same envelope: + + ```json + { + "error": "Unknown field 'pageSize' on object 'showcase_task'. Query parameters that are not reserved are read as field filters, so an unknown name can only match zero records. Did you mean the 'top' query parameter (OData spelling '$top')?", + "code": "INVALID_FIELD", + "field": "pageSize", + "object": "showcase_task" + } + ``` + + The rejection carries a suggestion — the canonical parameter for a known + dialect (`pageSize` / `perPage` / `page` / `sortBy` / `q` → `top` / `skip` / + `sort` / `search`), or the closest real field name when it reads like a typo — + and fires whether or not an explicit `filter` rode along, so the failure never + depends on which other parameters were sent. + + **What changes for callers:** a request sending a parameter that names no field + now gets a `400` where it used to get an empty `200`. Page size is `top` / + `$top` / `limit`; page offset is `skip` / `$skip` / `offset`. Every documented + parameter, every `$`-prefixed OData alias, and the full `QueryAST` body of + `POST /data/:object/query` are unaffected. An object with a field named after a + reserved parameter (`count`, `cursor`, `object`, `top`, `search`, …) filters it + through the explicit form: `?filter={"count":3}`. + +- 0931185: fix(rest,service-settings,service-datasource)!: four more route modules emit the declared envelope, and the guard is now shared (#3843) + + #3675 and #3689 moved `service-storage` and `service-i18n` onto the declared + response envelope (`BaseResponseSchema` + `ApiErrorSchema`). Each scoped itself + to one service, and neither asked whether the same drift existed elsewhere. It + did — in four more modules, and in two of them it was the _older_ shape, the one + #3675 had already declared wrong: + + | Module | before | now | + | ------------------------------------- | -------------------------------------------------------------- | ------------- | + | `service-settings/settings-routes.ts` | nested `error`, no `success` on any of 5 bodies | full envelope | + | `service-datasource/admin-routes.ts` | `{ error: '' }`, `message` a **sibling** | full envelope | + | `rest/external-datasource-routes.ts` | `{ error: '' }` + a private `ok` | full envelope | + | `rest/package-routes.ts` | 3 of 16 bodies had `success`, 2 failures had no `error` at all | full envelope | + + ## Breaking: where to read things now + + **Success payloads move under `data`.** The keys are unchanged — only their + depth. `unwrapResponse` in `ObjectStackClient` returns `body.data` when the flag + is present, so every SDK method (`packages.list()`, `datasources.external.*`) + resolves to exactly the object it always did. Raw `fetch` callers must add one + hop: + + ``` + GET /api/v1/datasources body.datasources → body.data.datasources + GET /api/v1/datasources/drivers body.drivers → body.data.drivers + GET /api/v1/datasources/:name body.datasource → body.data.datasource + GET /api/v1/packages body.packages → body.data.packages + GET /api/v1/packages/:id body.package → body.data.package + GET /api/settings body.manifests → body.data.manifests + GET /api/settings/:ns body.manifest/.values → body.data.manifest/.values + POST /…/external/validate body.ok, body.results → body.data.ok, body.data.results + ``` + + `SettingsNamespacePayloadSchema` and friends still describe those payloads + exactly; they now describe the envelope's `data` rather than the whole body. + + **Error bodies stop being a string.** `{ error: 'datasource_admin_error', +message }` → `{ success: false, error: { code: 'datasource_admin_error', +message } }`. Read `body.error.message`, not `body.message`; read + `body.error.code`, not `body.error`. This is the asymmetry #3675 opened on: a + caller reading `body.error.message` previously got the real message from the + dispatcher and `undefined` from these routes. + + **Two failures that never said why now do.** `DELETE /api/v1/packages/:id` + answered a bare `{ success: false }` and a bare + `{ success: false, failed, cleanups }`. They are now `PACKAGE_DELETE_FAILED` and + `PACKAGE_DELETE_PARTIAL`, with the per-item `failed` / `cleanups` arrays under + `error.details`. + + **Codes follow ADR-0112.** #3841 settled the vocabulary while this was in review: + `error.code` is SCREAMING_SNAKE and `ApiErrorSchema.code` is now the closed + `ErrorCode` union, so an unregistered code fails schema parse. Generic conditions + reuse the STANDARD catalog rather than becoming registered synonyms of it, per the + ledger's own guidance: + + ``` + datasource_admin_unavailable → SERVICE_UNAVAILABLE (standard) + external_service_unavailable → SERVICE_UNAVAILABLE (standard) + not_found / PACKAGE_NOT_FOUND → RESOURCE_NOT_FOUND (standard) + PUBLISH_FIELDS_MISSING → MISSING_REQUIRED_FIELD (standard) + INTERNAL → INTERNAL_ERROR (standard) + datasource_admin_error → DATASOURCE_ADMIN_ERROR (registered) + external_import_error → EXTERNAL_IMPORT_ERROR (registered) + PUBLISH_MANIFEST_INVALID → PACKAGE_MANIFEST_INVALID (registered) + PUBLISH_FAILED → PACKAGE_PUBLISH_FAILED (registered) + PACKAGE_DELETE_PARTIAL / PACKAGE_DELETE_FAILED / SETTINGS_ACTION_FAILED (registered) + ``` + + Which service is unavailable is carried by `message`. The seven registered codes are + added to `ERROR_CODE_LEDGER` under their owning packages — including a new + `@objectstack/service-datasource` entry. + + **`POST /external/validate` keeps its `ok`.** Unlike the `{ ok: true, key }` + #3689 retired from storage — a private second word for `success` — this `ok` is a + computed verdict over the federated objects (`results.every(r => r.ok)`). The + request can succeed while the verdict is false, so the two flags are not the same + field; `ok` moves inside `data` rather than being dropped. + + Consumers were taught both shapes first, so the two repos are not coupled by + merge order: objectui's `packages` readers were already tolerant + (`payload?.data ?? payload`), and its datasource page plus the generic + `type: 'api'` action runner now unwrap the envelope and read `error.message` + (the latter previously toasted `[object Object]` for any nested error). + + ## The guard is shared now, not copied + + `scripts/check-route-envelope.mjs` + `pnpm check:route-envelope`, wired into + `lint.yml` alongside the nine sibling `check:*` guards. Its load-bearing assertion + is structural rather than per-route: **it counts the response write sites per + module.** When every body goes through the `sendOk` / `sendError` pair that count + is fixed at two and does not grow with the route list — so a _future_ route that + hand-rolls a body fails the guard. That is the coverage a driven-body test can + never give, since it can only drive the routes that existed the day it was + written. + + This existed three times already as an open-coded regex block (storage error, + storage success, i18n error). Lifting it did more than deduplicate: a per-package + scan **structurally cannot notice a module nobody thought to convert**, and going + repo-wide found two the moment it ran — neither is in #3843's hand-written survey: + + - `plugin-sharing/share-link-routes.ts` — the fifth drifting module. No body + carries `success`, and one answers `{ ok: true }`, the private second word #3689 + retired from storage. Filed as #3983 and pinned by the guard; converting it is + breaking for share-link consumers and needs its own sweep. + - `metadata/routes/hmr-routes.ts` — declared **exempt** with a reason (dev-only + SSE endpoint, not on the SDK surface), not skipped. Three states, deliberately — + conformant / ratcheted / exempt — because that is the honest classification + ADR-0049 asks for. A route module the scan finds but the table does not declare + is an **error**, never a default: applying `2 / 1 / 1` to an unknown module would + let a new one pass by coincidence. + + It also drops the regex for the TypeScript AST, fixing two real bugs the copies + had. They stripped comments with `String.replace`, whose line-comment pattern also + ate `//` inside string literals and truncated the rest of that line — response + writes included. And `.json(` does not mean "write a response": `hmr-routes.ts` + calls `c.req.json()` twice to READ a request body, which a textual count reports as + two unenveloped responses. Comments and literals are not AST tokens, and + request-vs-response is a property of the callee, so both disappear. The script + carries a `--self-test` pinning each case — the nine sibling guards have none, but + both of these bugs survived a review of the regex version. + + **The i18n ratchet, stated rather than hidden.** `i18n-service-plugin.ts` is + declared at `responses: 5, ok: 4, err: 1` with a ratchet pointing at #3973. Its + error half _is_ consolidated (#3675), but each of its four read routes builds + `{ success: true, data }` inline. Those bodies are correct — that is not envelope + drift — but an unconsolidated builder is a weaker guard: a fifth read route could + get the shape wrong and only a driven test would notice. The numbers pin today's + structure exactly (a new inline body fails) and drop to the conformant `2 / 1 / 1` + when #3973 lands. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- ccd9397: fix(security)!: a sharing rule with no criteria now shares NOTHING instead of every record (#3896) + + `SharingRuleSchema` has always required `condition`, and its doc is explicit + that a predicate the compiler cannot lower is _"skipped and logged — never + seeded as a permissive match-all (ADR-0049)"_. The declared/seed path honoured + that. The two other ways to create a rule did not: + + - **`POST {basePath}/sharing/rules`** plucks its body field-by-field into + `SharingRuleService.defineRule`, which validated `name` / `label` / `object` / + `recipientType` / `recipientId` — and not `criteria`. A missing, `null`, or + **misspelled** key (`criterias`) was stored as `criteria_json: null`, answered + `201` with no warning, and evaluated as + `find(object, { filter: {}, context: SYSTEM_CTX })`: every record of the + object, up to 5000, granted to the recipient. Triggering it took a typo, not + an attacker. + - **Authoring a rule in Setup** is a direct `sys_sharing_rule` insert, which + never reaches `defineRule` at all. + + Empty criteria is now rejected everywhere a rule can be written, and — because + rules created before this gate are already in the table — the evaluator refuses + to act on one regardless of how it got there. + + - **`defineRule` rejects a match-all criteria** with + `VALIDATION_FAILED: criteria is required …`, alongside its other required + fields. Covers the REST endpoint, programmatic callers, and the seeder. + Rejected shapes: missing / `null` / `''` / `{}` / `[]` / `{ $and: [] }` / + unparsable JSON (e.g. a CEL source typed into the Criteria box). + - **The evaluator matches nothing** for such a rule and logs why, so a row + stored before this release under-shares instead of over-sharing: the next + reconcile _revokes_ the grants it had materialised. Both evaluation paths are + covered — the bulk `evaluateRule` and the per-record write-hook path. + - **`bindRuleCriteriaGuard`** fails `sys_sharing_rule` inserts with no + criteria as a field-level `VALIDATION_FAILED` (a 400 naming `criteria_json`), + so the Setup path reports the problem instead of saving an inert rule + (ADR-0078). Updates are checked only when the patch supplies + `criteria_json` — switching an over-broad legacy rule off must not require + inventing a criteria for it first. + - **The seed bootstrap's "empty condition = match-all" branch is gone**: a + missing or empty `condition` is now skipped and logged like any other + non-lowerable one. + - `POST {basePath}/sharing/rules` also accepts `criteria_json` as an alias for + `criteria`, matching the snake_case aliases the endpoint already takes for + `object_name` / `recipient_type` / `access_level`. + + **Migration.** There is no "share every record" sharing rule, and there never + usefully was one — the shape existed only as a failure mode. A rule that + relied on it must state its predicate (`criteria: { stage: 'won' }`), or, if + the object really should be readable by everyone, use the object's + organization-wide default (`sharingModel`) instead. Rules already stored with + a null `criteria_json` need no data migration: they stop granting on the next + evaluation and their existing grants are revoked. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/service-package@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 11b5bdb3a2..711fd9d9d4 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 bdbf443b98..c443e6cf3e 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,2878 @@ # @objectstack/runtime +## 17.0.0-rc.1 + +### Major Changes + +- 195ad76: fix(actions)!: failures speak HTTP — business rejections are 400, success is a single wrap (#3962) + + **BREAKING (raw-HTTP callers of `POST /api/v1/actions/...` only).** The + 200-with-inner-envelope wire was never a designed contract: no ADR or doc ever + specified it, it originated as the route's catch block reusing + `deps.success()`, and `/actions` was the only route of 12 that double-wrapped. + #3962 classifies it as a bug. Five defects traced back to that one extra layer + (the console's green toast on failed actions, `redirectUrl` never firing, a + marketplace install reported as installed when it failed, the client-envelope + divergence #3927 papered over, and crashes invisible to monitoring). + + The contract now, identical to `/data`: + + | Outcome | HTTP | Body | + | :------------------------------------------------------------- | :-------------------: | :-------------------------------------------------------------------- | + | Ran, returned | **200** | `{success: true, data: }` — single wrap | + | Ran, rejected (business rule / validation) | **400** | `{success: false, error: {message, code, details: {code?, fields?}}}` | + | Never dispatched (unknown / denied / wrong type / unavailable) | 404 / 403 / 400 / 503 | unchanged (#3930/#3951) | + | Crashed (`TypeError`, driver class, sandbox timeout) | **500** | unchanged (#3951) | + + A validation rejection carries `details.code: 'VALIDATION_FAILED'` and + `details.fields[]` — the exact payload #3937 fought for, now on the same wire + shape `/data` has always used, which `@objectstack/client` normalizes to + `err.code` / `err.fields` (#3927). A rejected flow is a 400 with + `details.code: 'FLOW_FAILED'`. The crash-vs-rejection discriminator (#3951, + error `name`) now selects 400 vs 500. + + `client.actions.invoke` / `invokeGlobal` still never throw: they fold every + failure status into `{success: false, error}`, read the single wrap on + success, and keep a NARROW legacy heuristic so a current SDK talking to a + pre-#3962 server still folds the old double-wrapped 200s correctly. + + **Migration for raw-HTTP third parties:** branch on the HTTP status — a + non-2xx is the failure, `error.message` / `error.details` carry the detail; on + a 200, `data` is the handler's return value directly (one level less than + before). Callers using `@objectstack/client` need no change. + +- 698cbc2: feat(runtime)!: action params are enforced by default, and the opt-in is gone (#3438, ADR-0104 D2) + + A request bag that violates an action's declared `params[]` — a missing + `required` param, a value outside its `options`, a scalar where `multiple` + declares an array, a non-id where a `reference` declares one, or a key the + action never declared — is now **rejected before the handler runs**: + `400 VALIDATION_FAILED` on REST, a thrown error on MCP. It used to be logged + and passed through. + + ```diff + - OS_ACTION_PARAMS_STRICT_ENABLED=1 # removed — enforcement is the default + + OS_ALLOW_LAX_ACTION_PARAMS=1 # escape hatch: warn and pass, as before + ``` + + **What breaks.** Only calls that were _already_ wrong. The declaration was a + complete contract that informed nothing but the client dialog, so a bag the + server accepted could still have been silently ignored by the handler — which + is exactly how a correctly-intended `reference: 'sys_user'` degraded into a + paste-a-UUID box (#3405) with a success envelope on top. Those calls now fail + loudly instead of quietly. Actions declaring no `params` are untouched, and the + dispatcher's own `recordId` / `objectName` are allowlisted + (`ACTION_PARAM_BUILTIN_KEYS`), so the keys dispatch itself merges in were never + candidates for the unknown-key error. + + **Fixing a rejection** takes one edit at the call site: the message names the + offending param and the declared list. If an integration you cannot reach in + time is affected, set `OS_ALLOW_LAX_ACTION_PARAMS=1` to restore the old + pass-through — the violation still logs once per action, so the drift stays + visible rather than becoming invisible again. + + **Why 17.0 rather than a warn window in 17 and the flip in 18.** R3 asked for + warn-then-error, and ADR-0104's 2026-07-30 addendum declined it on the merits + rather than postponing. What a violation strands is a **caller**, not data: the + rejection reaches a developer or an agent who can fix it in one edit, no stored + row is made unwritable, and the escape hatch makes it reversible in a restart. + Deferring that by a major would have charged every deployment a second upgrade + ceremony — 16→17 is already a substantial, tested migration — to postpone a + break that costs one edited call. v17 already carries harsher zero-window + flips (`allowExport` unset now means denied; an undeclared action handler 404s + with no opt-out at all), so holding the milder change to a stricter standard + would have been inconsistent rather than cautious. + + For AI and MCP callers specifically — the population D2 was built for — a 400 + is corrective feedback consumed in-loop, while a server-side warning is + feedback nobody ever reads. + + D1's value-shape half went the opposite way for the opposite reason: it rejects + on the basis of **stored data**, which an author cannot edit their way out of, + so it stays gated per deployment on that deployment's own migration evidence. + +- ffb003c: **ADR-0110 — an action's identity is its `name`, and anything executable over a + governed surface must have a declaration.** + + `POST /api/v1/actions/:object/:action` resolved the DECLARATION from the URL + segment as a `name` but dispatched the HANDLER using that same segment as a + registry key. For a target-bound action (`{ name: 'complete_task', target: +'completeTask' }`) those are different strings, so the two documented callers + each worked on exactly the half the other broke: the documented curl resolved + the declaration then 404ed, while the Console's `target`-addressed call + dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4 + capability gate and the ADR-0104 param contract (#3935). + + - **D1/D2** — identity is always the declarative `name`; the handler key is + derived from the resolved declaration through a rotation now shared with the + MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`). + The REST route previously rotated only the object key, never the handler key. + - **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely + undeclared handler is **refused (404)** with the `defineAction` to add, rather + than executed ungated with system privileges; an unreachable metadata plane is + a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells + a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration + valve — it warns on every invocation and is removed in 18. + - **D5** — `reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions` + power a `kernel:ready` inventory logging every registered-but-undeclared + handler (refused at dispatch) and every declared script action bound to no + handler — the ADR-0078 converse, mechanised. + - **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in. + + Apps whose actions are all declared need no changes beyond gaining enforcement + of the `requiredPermissions` they already declared. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- 347f460: **[ADR-0110 D3, revised] The undeclared-action refusal has no opt-out — + `OS_ALLOW_UNDECLARED_ACTIONS` is removed before 17 ships.** + + D3 as accepted refused an undeclared handler but shipped + `OS_ALLOW_UNDECLARED_ACTIONS=1` as a migration valve that ran it anyway, + "slated for removal in 18". Removed now, for two reasons: + + - **It contradicts the ruling it accompanies.** A flag that executes an + ungoverned, system-elevated handler _is_ the fail-open D3 closes. ADR-0049's + trichotomy has no "enforced unless a flag says otherwise" state. + - **It had no observed users.** A reconciliation sweep across the platform + packages, every example and every plugin found the only `engine.registerAction` + call sites are `app-todo`'s eight, all declared. The valve would have shipped + a documented way to reopen the gate for a population nobody has ever seen. + + What it was buying is covered without it: the app still boots, every declared + action still works, D5's boot inventory names each offender at startup, and the + 404 names the `defineAction` to add. Migration costs a code change rather than + an env var — the correct price for reopening an authorization gate. + + Setting the retired variable has no effect; a regression test pins that, so a + stale deployment script fails loudly rather than silently re-opening the gate. + +- 8a341a4: A dispatcher domain whose route is mounted but whose implementation is absent answers **501**, not a 404 that blames the route (#4093 follow-up). + + Two different facts were being answered with whatever each domain happened to reach for, and only `mcp` told them apart: + + - **The route is not there.** `/mcp` when the server is disabled for the environment; `/analytics` when the service is unserveable, because `dispatcher-plugin` gates the _mount_ and never registers those paths (#4000). A path the server does not expose is a **404** from the host's own router. **Unchanged** — that half was already right. + - **The route is there; the implementation is not.** Every unconditionally-mounted domain. The request reached a handler that had nothing to delegate to. That is **501**, and it is what changes here. + + `/automation` and `/notifications` returned `{ handled: false }`, which looks neutral but lands on the dispatcher plugin's single exit: `404 ROUTE_NOT_FOUND` with the hint _"No handler matched this request. Check the API discovery endpoint for available routes."_ Both halves were false — a handler did match, and discovery correctly does not list the route, so the hint pointed at a page that would never mention it. An operator reads that as a routing bug and goes looking for one that does not exist. + + `/ui` answered **503**, which claims the condition is temporary. An uninstalled MetadataPlugin does not become installed by retrying. + + `/ai` answered **404** for the same mounted-but-unimplemented case. + + The refusal now carries the same remedy sentence discovery reports for that slot (`serviceUnavailableMessage`, shared via `@objectstack/spec/system`), so the wall and the discovery entry cannot drift into naming different fixes — `POST /api/v1/automation` on a stack without the service answers `501 Install @objectstack/service-automation to enable`. `/ai` keeps a local message: its real provider ships outside this workspace as a Cloud/EE package, so the shared table — verified against workspace packages — records no entry and would otherwise describe it as "nothing ships". + + Deliberately unchanged: `analytics`'s route-mount gate (a genuinely absent path); `mcp`'s 404-vs-501 pair, which is the model; the `handled: false` at the END of each domain, which means "no sub-route matched" and is a true 404; `GET /ai/agents`'s empty-list 200, a deliberate courtesy for the console's per-navigation poll; and `/ai`'s `503 routes not yet initialized`, which is a different condition (service present, internal state unready) and may genuinely be transient. + + FROM → TO: requests to `/automation`, `/notifications`, `/ui/*` and `/ai/*` on a deployment lacking the backing service now get `501` (with the package to install) instead of `404`/`503`. Anything branching on the old status should branch on `status >= 400` or read the error code; the capability was equally unavailable before, so no working flow changes. Discovery is unaffected — it already reported these slots `unavailable` and advertised no route for them. + +- 8dcc0f5: feat(runtime)!: retire the inert `DriverPluginOptions` — `DriverPlugin` takes `(driver, driverName?)` (#4320) + + `new DriverPlugin(driver, { datasourceName, registerAsDefault })` never did + what it promised: both options configured a datasource-registration block in + `start()` gated on `metadata.addDatasource`, a method **no metadata service + implements** — so the block early-returned on every boot since inception and + the options were dead weight (found while typing service lookups for #4251). + + **Migration** — delete the options argument; nothing changes at runtime + because nothing ever happened: + + - FROM `new DriverPlugin(driver, { datasourceName: 'x', registerAsDefault: false })` + TO `new DriverPlugin(driver)` + - FROM `new DriverPlugin(driver, 'name', options)` TO `new DriverPlugin(driver, 'name')` + - The string second argument (`new DriverPlugin(driver, 'memory')`) is unchanged. + + If you passed `datasourceName` expecting routing to a named auxiliary driver: + that routing never came from the option. It keys off the **driver name** — + `DriverPlugin.init()` registers `driver.`, ObjectQL's discovery loop + adopts it, and the engine's lifecycle/datasource resolution looks the name up + (see the telemetry provision in `os serve` for the pattern: stamp + `driver.name`, register the plugin, done). For Setup → Datasources visibility, + declare the datasource through `DatasourceConnectionService` / + `registerInMemory('datasource', …)` (ADR-0062). + + The `DriverPluginOptions` interface was module-local (never exported from the + package root), so the only public break is the constructor's second/third + argument shape. + +- 5b08389: The `/auth` domain no longer fabricates a login. With no auth service registered it answers **501**; the mock that answered 200 with a made-up session is deleted (#4113). + + `packages/runtime/src/domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with **200 and a fabricated user plus a 24-hour `mock_token_*` session — for any email and any password, which was never read**. It shipped in `@objectstack/runtime` rather than behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without `@objectstack/plugin-auth` served it. + + It was never a bypass: no session store backs the token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was worse in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. Its stated justification ("MSW/browser-only environments") had no consumer in this repository or in `objectui`, whose auth tests mock at the HTTP client layer; the only things pinning it were tests asserting the mock itself. + + ADR-0115 retired this whole class of fabricating fallback inside `plugin-dev`. This was its last surviving member and the only one that shipped to production; the lineage before it — the #3891 analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio — was retired the same way: deleted, not put behind a flag. + + **501 rather than 404**, following `/i18n`, the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route is mounted; what is missing is the implementation behind it — which is what 501 states and 404 would misdescribe. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, which is the sharper case: the slot is filled, so discovery advertises `routes.auth`, and that request previously got a fabricated session. + + FROM → TO: a deployment without an auth service now gets `501 "Auth service not available — register @objectstack/plugin-auth to enable authentication"` on `/api/v1/auth/*` instead of a 200 carrying a session that never worked. Install `@objectstack/plugin-auth` (it is in the default `os serve` preset), or treat the absence as production already required — the 200 never produced a session the identity path accepted, so no working flow depended on it. Front-ends that mocked auth through this fallback should mock at the HTTP client layer or with an MSW handler, as `objectui` already does. + +### Minor Changes + +- 6e141bc: fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up) + + #3937 settled that a failed action reports in the payload at HTTP 200 — "an + action that fails is a normal outcome, not a transport error". That is a + statement about the action **rejecting**: a business rule saying no. The same + exit was also covering a third case it never argued for. + + A `TypeError` in a handler, a driver blowing up, a sandbox timeout — those are + not outcomes the action chose to report, they are the server failing to produce + one. Serving them as 200 hid **every handler crash** from the layers that exist + to catch server faults: gateway error rates, retry and circuit-breaker policy, + APM auto-capture, alerting, `fetch().ok`. For a platform whose main extension + surface is customer-authored script bodies, "customer action bodies are + throwing" had no signal short of body-parsing at every hop. + + Those are **500** now, through the same `errorFromThrown` exit every other + domain catch has used since #3925 — which also means a driver dump finally goes + through the internal-error-leak sanitiser (#3867) instead of reaching the client + verbatim in a 200 body. + + **Nothing #3937 put in the payload moves.** A rejection and a crash are told + apart by the error's NAME, the signal `@objectstack/rest` already uses on this + exact distinction ("non-default names (`TypeError: …`) […] signal a genuine + script bug rather than a deliberately thrown business rule"): + + | Thrown | Verdict | Wire | + | :------------------------------------------------------------------ | :------------------------ | :------------ | + | `new Error(msg)` — a registered handler rejecting | rejection | 200 + payload | + | `SandboxError` with `innerMessage` — a body's deliberate throw | rejection | 200 + payload | + | Anything carrying `code` / `fields`, or a `ValidationError` by name | rejection | 200 + payload | + | A throw with no `name` at all | _not confidently a fault_ | 200 + payload | + | `TypeError` / `ReferenceError` / `SqliteError` / a driver's class | crash | **500** | + | `SandboxError` with no `innerMessage` — timeout, capability denial | crash | **500** | + + Deliberately the narrow direction: only what is _certainly_ a fault moves, and + everything uncertain keeps the 200 it has today. + + One related fix in the same exit: an error carrying its own `status` / + `statusCode` (a plugin's `FORBIDDEN` with `status: 403`) is now served with it + rather than buried in a 200 payload — that status was the one thing the thrower + was unambiguous about. Record `ValidationError`s deliberately carry no + `.status`, so #3937's cases never reach that branch. + + Documented in `api/error-catalog.mdx` (new **Action Errors** section with the + full status table and the two-check pattern a raw `fetch` caller needs) and + `ui/actions.mdx`. + +- a4e2684: feat(runtime): the sandbox reports an action body's discarded `ctx.record` writes at invocation time (#4345) + + #4362 closed the author-time half of #4345: `action-record-write-discarded` + warns when a body assigns to `ctx.record` and the snapshot is provably dead. + This is the run-time half, and it exists because a parse cannot reach three + things a running action can: + + - **computed keys and aliases** — `ctx.record[k] = v`, `const r = ctx.record; +r.x = 1`, which the lint deliberately skips rather than guess at; + - **a wholesale replacement** — `ctx.record = {…}`; + - **bodies no lint ever sees** — metadata authored through Studio or the API + never passes through `os validate` / `os lint` / `os compile`. + + The sandbox installs a `set`/`deleteProperty`/`defineProperty` proxy over the + snapshot, behind an accessor so a wholesale replacement cannot swap the recorder + out, and surfaces the touched keys as `ScriptResult.droppedRecordWrites`. + `actionBodyRunnerFactory` logs a warning naming the discarded fields and the + `ctx.api.object(...).update(...)` remedy. Writes still work _inside_ the VM, so + a body using the snapshot as scratch keeps its reads coherent — only the silence + is removed. + + **Only dead writes are reported**, on the same reading #4362 uses: a snapshot + that leaves the body as a value may have carried the write with it, so + + ```js + ctx.record.stage = "won"; + await ctx.api.object("crm_deal").update(ctx.record); // lands — stays quiet + ``` + + is not reported, while a plain property read does not rescue a write (the + `ctx.recordId || (ctx.record && ctx.record.id)` guard idiom real action bodies + are written with still reports). An `ownKeys` after a write marks the escape. + A wrong "discarded" asserts something false about the stored record, which is + worse than a miss. + + Hooks carry no `record`, so they install no proxy and pay nothing. `ctx.record` + remains read-only; whether the runtime should instead refuse or honour the write + is still open — reporting a discard prejudges neither answer. + +- c2bbd97: fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913) + + **1 — the registration key and the lookup key disagreed.** Both writers + register an objectName-less action under the literal `'global'`: `AppPlugin` + (`action.object || 'global'`) and `ObjectQLPlugin.actionObjectKey`. The REST + route's fallback probed `'*'`, and `engine.executeAction` is an exact-string + `Map` lookup with no wildcard semantics — so the probe could only ever miss: + + ``` + Action 'log_call' on object '*' not found + ``` + + `POST /api/v1/actions/global/log_call` worked by **accident** (the path segment + happened to spell the registration key); `POST /api/v1/actions//log_call` never + worked at all, and neither did falling back from an object-scoped route to a + global handler. `'global'` is now the canonical key + (`GLOBAL_ACTION_OBJECT_KEY`), the probe order is + `[, 'global', '*']` for both the REST route and the MCP + `run_action` bridge (`actionHandlerObjectKeys` — one list, two surfaces), and a + single-segment path (`/actions//:action`) routes at `'global'` instead of + 400-ing. A handler registered directly under `'*'` still resolves; the doc + comments that called `'global'` a "wildcard" are corrected at every site. + + **2 — "no such action" was reported as a success.** The not-found exit called + `deps.success(...)`, which always emits `{status: 200, body: {success: true, +data}}`, so a request naming an action that does not exist came back as: + + ```json + { + "success": true, + "data": { + "success": false, + "error": "Action 'log_call' on object '*' not found" + } + } + ``` + + Every caller that did not hand-unwrap the INNER envelope read the outer + `success: true` and reported a success that never happened — including the + shipped console, which showed a green toast (fixed on that side in + objectui#2963). Nothing **dispatched** there, so it is a **404** now, joining + the answers this route already gives a status: 403 denied, 400 wrong action + type, 503 unavailable. The miss also names the **routed** object rather than + whichever probe ran last (the old fallback said `on object '*'`, an object the + caller never asked for). + + A handler that **ran and rejected** is unchanged: HTTP 200 with + `data: {success: false, error, code?, fields?}`. That is a business outcome, + not a transport error, and #3937 pins it. The line is "did a handler run" — + below it the payload, above it the status. + + `client.actions.invoke` / `invokeGlobal` still do **not** throw. `client.fetch` + throws on every non-2xx, so `invoke` now catches and folds a dispatch failure + into the same `{ success, data?, error? }` result with `error` as a plain + string — otherwise the routes that just gained a status would have started + propagating exceptions into callers that only ever checked `result.success`. + +- 32ccb23: feat(spec,core,runtime)!: ADR-0112 batch 1 — one error-code vocabulary, SCREAMING_SNAKE, schema-enforced (#3841) + + Settles #3841 per ADR-0112: the top-level `error.code` vocabulary is + SCREAMING_SNAKE, in two tiers. + + - **`StandardErrorCode` members renamed in place** (`validation_error` → + `VALIDATION_ERROR`, all 53). Breaking for importers that branch on the old + lowercase members; the type name and member _meanings_ are unchanged. + - **New `ERROR_CODE_LEDGER`** (`@objectstack/spec/api`): service-specific codes + (`AUTH_REQUIRED`, `VALIDATION_FAILED`, `ATTACHMENT_DOWNLOAD_DENIED`, …) are + registered per owning package. `ErrorCode` = standard ∪ registered. + - **`ApiErrorSchema.code` is now `ErrorCode`**, not `z.string()` — an + unregistered code fails parse, so the envelope conformance suites assert + values, not just shape. + - **`FieldErrorSchema.code` widened to `z.string()`** (ADR-0112 D6): field-level + codes are a separate vocabulary the enum never described; #3977 owns its real + catalog. + - **Derived codes changed case on the wire**: `standardErrorCodeForHttpStatus` + now yields SCREAMING members (`permission_denied` → `PERMISSION_DENIED`, + `method_not_allowed` → `METHOD_NOT_ALLOWED`, …) — this map was #3842's + designated one-file sweep point for exactly this decision. + - **`ANONYMOUS_DENY_CODE` is `'UNAUTHENTICATED'`** (was `'unauthenticated'`) — + the promoted code on anonymous-denied requests and the REST `enforceAuth` + body change spelling with it. + + `error-catalog.mdx` and the error-handling guides are rewritten to the single + vocabulary; a spec test now locks the catalog page's headings to the enum so + they cannot drift apart again. Remaining lowercase emitters (cloud-connection, + plugin-auth envelope codes, metadata-protocol, …) are the batch-2 sweep. + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 0f12193: feat(runtime): mount /analytics routes only when the capability exists (#3891 follow-through, ADR-0076 D11) + + `createDispatcherPlugin` used to mount `POST /analytics/query`, + `GET /analytics/meta` and `POST /analytics/sql` on the `IHttpServer` + unconditionally — so a deployment without `@objectstack/service-analytics` + still had the routes in its table: a `PUT` answered `405` with + `Allow: POST`, advertising a method on an API that wasn't there (the `POST` + itself answered 404 since #3989). + + The mounts are now capability-conditional. Plugin `start()` runs after the + kernel's Phase-1 init, so service presence is authoritative: + + - **single-kernel mode, `analytics` not registered** — the three routes are + NOT mounted; every method on `/api/v1/analytics/*` answers the adapter's + shared not-found contract (`404 { "error": "Not found" }`), and a boot log + names the fix (`Install @objectstack/service-analytics`); + - **single-kernel mode, `analytics` registered** — unchanged; + - **multi-tenant host** (a `kernel-resolver` is wired) — mounted + unconditionally, because mounts are host-global while the analytics service + lives in each per-project kernel: capability presence is a per-request + question, answered by the analytics domain's existing `handled:false` → 404 + (new public `HttpDispatcher.isMultiTenantHost()` exposes the mode). + + With this, the `/analytics` API surface exists exactly when the capability is + installed — completing the #3891 arc: #3989 emptied the slot (no more + unscoped-aggregate shim), #4010 made the body contract strict at the entry, + and this change removes the last wire-level residue of the uninstalled API. + +- 9b6fe7c: fix(spec,runtime)!: `AnalyticsQueryRequest` is the bare `AnalyticsQuery`; the dispatcher validates `/analytics` bodies at the entry (#3878) + + **Spec.** `AnalyticsQueryRequestSchema` used to describe a + `{ cube, query: {...}, format }` ENVELOPE — the dialect of the retired degraded + analytics shim (#3891), which the real engine never understood: an envelope + body inferred a column-less cube and died as an SQL syntax error + (`SELECT FROM …`) instead of a shape error. The schema now describes what the + engine and every real caller actually use — the **bare `AnalyticsQuery`**: + + ``` + FROM { "cube": "orders", "query": { "measures": ["count"] }, "format": "json" } + TO { "cube": "orders", "measures": ["count"], "dimensions": [...], "where": {...} } + ``` + + `cube` + `measures` are required at the top level; `dimensions` / `where` / + `timeDimensions` / `order` / `limit` / `offset` / `timezone` sit beside them. + The schema is `.strict()`; `query` and `format` are tombstoned (`retiredKey`) + so both `tsc` and the parse answer with this exact migration. `format` was + never implemented (every response is the JSON envelope) — for CSV/XLSX use the + export surface. The removal is registered as two step-17 semantic migrations + (`analytics-query-request-envelope-retired`, + `analytics-query-request-format-retired`) — it is an HTTP-wire change with no + stored metadata to rewrite. + + **Runtime.** `POST /api/v1/analytics/query` and `/analytics/sql` now validate + the body against that schema AT THE ENTRY and answer + **400 `VALIDATION_FAILED`** with per-field details — including the envelope + prescription above, and a bespoke hint that `filters` is not a contract field + (the filter field is `where`, the same canonical FilterCondition `find()` + takes). Previously a malformed body reached the engine and failed as a 500 SQL + syntax error, or had its off-contract filter key silently ignored. A valid + body is forwarded to the analytics service byte-identical (validation only — + parsing would inject the schema's `timezone: 'UTC'` default and override + org-timezone resolution). An uninstalled analytics capability still answers + 404 before any body inspection (#3891). + +- c9d254a: feat(datasource,runtime): kernel teardown disconnects through the one datasource path — and never closes an adopted pool (#3993) + + After the #3826 connect convergence, ADR-0062 D5's "owns connect/disconnect" + was half-true: nothing disconnected the `default` (or a declared datasource's + pool) on graceful shutdown. `DriverPlugin` never had teardown, `ObjectQLPlugin` + teardown never touched drivers, and the kernel's actual teardown phase is + `destroy()` — the Plugin contract has no `stop()`, so stray `stop` methods were + never called by anything. + + The disconnect half now mirrors the connect half: + + - **`DatasourceConnectionService.disconnect(name, { asDefault })`** resolves + the default under its NATURAL name (the same #3826 rule that makes + `drivers.get('default')` impossible — the old lookup could never have found + it), and honours a new ownership discriminator recorded at connect time. + - **`disconnectAll()`** closes exactly the pools THIS service opened — + `'connected'` states only. `already-registered` drivers belong to whoever + registered them (an `onEnable` bridge, the default's idempotent replay) and + are never touched. + - **`DatasourceDriverHandle.ownership: 'factory' | 'host'`** is the + discriminator. `createPrebuiltDriverFactory` stamps its handles `'host'`: + an ADOPTED instance's pool outlives the kernel (the cloud control-plane + driver doubles as every environment kernel's proxy base; per-environment + drivers are registry-cached across kernel rebuilds), so kernel teardown — + including a cloud LRU eviction's `kernel.shutdown()` — clears the retained + verdict but NEVER closes the pool. Factory-built instances disconnect as + before there was a before. + - **`DefaultDatasourcePlugin.destroy()`** and + **`DatasourceAdminServicePlugin.destroy()`** wire the sweep at the kernel's + real teardown phase, best-effort (a failed disconnect never masks shutdown). + + A welcome side effect: a file-backed `sqlite-wasm` default with + `persist: 'on-disconnect'` now actually flushes on graceful shutdown. + + Also flips ADR-0062's status to reflect the completed convergence (#3992): + D1 is fully implemented across both repos since cloud#915; the remaining + `DriverPlugin` uses are documented named-auxiliary/escape-hatch cases, and the + degraded-boot parity guard stays with its role shifted to "the escape hatches + must not drift". + +- c3bcb42: feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826) + + ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question + open: a host whose `default` needs a driver the shared factory cannot build — + the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the + cloud control-plane driver doubles as the proxy base of every environment + kernel; per-environment drivers are cached across kernel rebuilds) — had only + two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose + connect verdict lives in `ObjectQLEngine.init()` (the second implementation + #3826 exists to retire), or fork the connect orchestration. Either re-opens the + #3741 → #3758 drift this whole line of work is about. + + Two additive pieces close it: + + - **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`** + (defaults to the shared open-core factory, byte-for-byte unchanged when + omitted). The factory only changes what `create()` returns — the policy-free + init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE` + escape hatch, and the start() replay into retained admin state are identical + either way, and the new tests pin that (an adopted instance that cannot + connect takes the exact same verdict). + - **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in + `@objectstack/service-datasource` — the "adopt an existing driver" seam the + first #3826 pass found missing, landed AS a factory so it composes into the + one connect path instead of becoming a second entry point. `create()` returns + the SAME instance every call: construction, pooling, and reuse stay host + concerns; only the verdict converges. Not for the common case — a `default` + expressible as `{ driver, config }` should stay a plain definition. + + The `@objectstack/verify` dogfood harness now boots through + `DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a + pre-built `DriverPlugin` — so the dogfood gate exercises the same declared + -default connect path `objectstack dev`/`serve` use, which is the §Risk + mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have. + The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is + still live for the boot re-verification, `DriverPlugin` escape-hatch drivers, + and the cloud compositions until they converge onto this seam. + +- 03d26f7: fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842) + + `HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it + straight into the field `ApiErrorSchema` reserves for a semantic string, so + `error.code` came back as `400`/`403`/`503` — a number, duplicating the response + status and occupying the one slot a caller is meant to branch on. The real code + then had to go somewhere else, and did, three somewhere-elses: `details.code` + (auth gate, permission denial, anonymous deny), `details.type` + (project-membership gate), and `error.type` (`routeNotFound`). Four sites, three + parking spots, because the declared one was full. + + **FROM → TO on the wire.** A dispatcher error body + + ```json + { + "success": false, + "error": { + "message": "…", + "code": 403, + "details": { "code": "PERMISSION_DENIED" } + } + } + ``` + + is now + + ```json + { + "success": false, + "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } + } + ``` + + | Reading | Was | Now | + | ------------- | ---------------------------------------------------------- | ------------------------------------------------- | + | semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` | + | HTTP status | `error.code` | `error.httpStatus` (or the response status) | + | context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) | + + **One-line fix for a direct reader:** replace `body.error.details?.code ?? +body.error.type` with `body.error.code`, and `body.error.code` with + `body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient` + already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still + reads the old shape, so a client newer than its server is unaffected. + + Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`, + `ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, + `VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not + rename anything. Reconciling the repo's two code vocabularies is #3841, and this + leaves it exactly one map and one enum to sweep instead of four parking spots. + + A branch with no code of its own is served one derived from the status, via the + single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus` + in `@objectstack/spec/api` (`403` → `permission_denied`, `503` → + `service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code` + is required; drawing it from `StandardErrorCode` keeps a derived code a + catalogued one rather than an invented string. + + **Spec changes:** + + - `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is + `EnhancedApiErrorSchema.httpStatus`. Additive. + - `StandardErrorCode` gains `method_not_allowed` and `precondition_required`, + the two statuses the runtime returns that the enum could not name. Additive. + - **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string + spellings of HTTP statuses, for matching against the numeric `error.code`). It + is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' | +'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum + declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO + `DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read + `error.httpStatus`. TypeScript flags every call site. + - **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()` + (was `z.number().int()`), `error.type` is **removed** (folded into `code`), and + `error.httpStatus` / `error.details` are declared. This schema is what + legitimised the deviation — it declared the opposite of `ApiErrorSchema` for + the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO + `{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`. + + **Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s + `errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405. + `errorResponseBase` previously discarded a thrown error's `.code` outright — it + had nowhere to put it — so the two exits of one surface disagreed about what a + caller would see; they now agree. Every body on this surface is built by one + helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by + `error-envelope.conformance.test.ts`: each branch driven and parsed against the + schema imported from `packages/spec`, plus a source scan so a new branch cannot + quietly reintroduce a numeric `code` or a `type`-as-code sibling. + + This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be + deleted rather than updated once the dispatcher was fixed. + +- 33a5ff4: `os migrate` no longer touches the database before you confirm, and refuses a + SQLite database another process is using (#3917). + + **Nothing is written before the prompt.** `plan` called itself a dry run and + `apply` gated on `[y/N]`, but both booted the full plugin set first — and boot + schema-sync issued create-table/add-column DDL (plus the artifact's inline seed + wrote rows) against the target database before either promise was kept. + `SqlDriver` gains `setDeferredDdl` / `previewDeferredSchemaWork` / + `flushDeferredSchemaDdl`: while armed, `initObjects` still registers every + in-memory map drift detection depends on but records the physical work instead + of performing it. Both commands boot with it armed, render the held-back work + as a `New (additive)` section of the plan, and `apply` performs it only after + confirmation. `os meta resync` / `os migrate files-to-references` keep the old + behaviour — they need the tables to exist. + + **Occupancy check.** A live `os dev`/`os serve` holding the same SQLite file is + the usual way a migration goes wrong: the migration is transactional and swaps + tables inside the file, but the running server keeps prepared statements and a + schema cookie the migration invalidates. `os migrate` now probes the target + before booting — `PRAGMA locking_mode = EXCLUSIVE` + `BEGIN IMMEDIATE` under + `busy_timeout = 0`, which reports `SQLITE_BUSY` when another connection is + _attached_, not merely writing. (`wal_checkpoint(TRUNCATE)` only sees an active + writer, and `-wal`/`-shm` presence cannot tell a live server from a crashed one; + both are encoded as tests.) `apply` refuses with exit 1 — `error: database_busy` + under `--json` — unless the new `--force` flag is passed; `plan` warns and + continues, since it writes nothing either way. SQLite only: Postgres and MySQL + take their own server-side locks. + + `@objectstack/runtime` also exports `resolveStandaloneDatabase()`, so a caller + can resolve the database target with the same precedence the boot uses without + building the stack, and `createStandaloneStack` accepts `skipSeedData`. + +- d13004a: feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + + `kernel.use()` registration order was never a contract — the kernel resolves + init/start order from the plugin dependency graph — but a plugin that needed a + service at init _when its provider is composed_ while also booting _without_ + the provider had no way to declare that. `AppPlugin` was the standing example: + it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing + (a hard dependency would break empty-env / metadata-only / mock-engine + kernels), and so its correctness rode on which array slot each caller put it + in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s + first cut; then #4085, disguised for months as "crashes when the artifact is + missing"). + + The kernel `Plugin` contract gains three additive fields, enforced by both + `ObjectKernel` and `LiteKernel` through one shared implementation + (`plugin-order.ts` — the previously duplicated topological sort is unified + there): + + - **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. + - **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. + - **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + + Plugins that declare nothing get the diagnosis too: a `getService` miss + during Phase 1 now appends which plugin was initializing and — when a + composed plugin declares the service — who provides it and how to declare the + ordering. The `Service '' not found` prefix and the factory-backed + `is async - use await` message are unchanged. + + First adopters: `AppPlugin` declares + `optionalDependencies: ['com.objectstack.engine.objectql']` + + `requiresServices: ['manifest']` (cleared on the empty-env no-op path), so + the #4085 composition — AppPlugin registered before the engine — now boots + correctly in every slot; `ObjectQLPlugin` declares + `providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and + `MetadataPlugin` declares `providesServices: ['metadata']`. + + Everything is additive — plugins that declare nothing keep their exact + ordering semantics; no existing declaration changes meaning. + +- a1b61e0: Request bodies are now checked against the schemas the API catalog declares for them (#3899, the request-side dual of #3877). + + **Routes that now answer `400 VALIDATION_FAILED` + `fields[]` for a body violating their declared `requestSchema`** (previously the body was consumed raw, and a malformed one silently executed different semantics): + + - `POST /data/:object/query` — body must be a QueryAST (`FindDataRequestSchema`); a garbage body used to degrade into an unfiltered full read. The path `object` is now pinned into the forwarded query (a body `object` can no longer contradict the path). + - `POST /data/:object` / `PATCH /data/:object/:id` — body must be a record object (`CreateDataRequestSchema` / `UpdateDataRequestSchema`). + - `POST /data/:object/batch` — body must be a `BatchUpdateRequestSchema` (`operation` + `records[]`). + - `POST /data/:object/createMany` — body must be a bare JSON array of records (`CreateManyDataRequestSchema`); `{ records: [...] }` (updateMany's envelope) is rejected with a pointer. + - `POST /notifications/read` — body must be `{ ids: string[] }` (`MarkNotificationsReadRequestSchema`); a misnamed key used to become `markRead(userId, [])` — a 200 no-op that never cleared the badge. + + **Dispatcher automation routes now validate their bodies** (no catalog schema; hand-written guards): + + - `POST /automation` and `PUT /automation/:name` require a flow-definition object, and POST requires a non-empty `name` — a mistyped `name` used to register the flow under the key `undefined` and echo 200. + - `POST /automation/:name/toggle` is strictly `{ enabled?: boolean }` — `{"enable": false}` (one letter off) used to ENABLE the flow and answer 200 `{enabled: true}`; it is now a 400 naming the offending key. An empty body still means enable. + + **`QuerySchema` now declares the search contract ADR-0061 actually serves** (additive): `search` accepts the canonical bare query string as well as the structured `FullTextSearch` form, and the server-validated `searchFields` narrowing is formally declared. Previously the schema declared only the object form while every surface (and the ADR's own conformance proof) sent the string — drift that surfaced the moment request bodies started being validated. + + **Catalog corrections in `@objectstack/spec` (`plugin-rest-api.zod.ts`)** — documentation-only tables: + + - `DEFAULT_NOTIFICATION_ROUTES` drops the four device/preferences endpoints — those server routes were removed in #3612 (never built), yet the table kept declaring them, `requestSchema` and all. + - `DEFAULT_AUTOMATION_ROUTES`' trigger endpoint path is corrected `/trigger` → `/trigger/:name` (the mounted path; the flow name rides the path) and its `AutomationTriggerRequestSchema` declaration is removed — that schema never described this route's wire shape. + - `DEFAULT_DATA_CRUD_ROUTES` gains the `POST /:object/query` entry (mounted since forever, previously undeclared), repoints create/update to the schemas the routes actually validate (`CreateDataRequestSchema` / `UpdateDataRequestSchema` — the old `CreateRequestSchema`/`UpdateRequestSchema` names described a `{ data }` envelope the wire never had), and drops `requestSchema` from GET/DELETE entries (path/query-bound inputs; nothing can violate them as a body). + - New gates: catalog `requestSchema`/`responseSchema` strings must resolve to real exported Zod schemas, `requestSchema` may only sit on body-carrying methods, and every declared `requestSchema` on a mounted route has a violating-body → 400 conformance case (`packages/rest` + `packages/runtime` request-schema-gate suites). + + Migration: clients that already send the documented shapes are unaffected. If you relied on a malformed body being silently accepted (e.g. posting `{ records: [...] }` to `createMany`, a non-boolean `enabled` to toggle, or an off-schema analytics/query body), fix the request to the declared shape — the 400's `fields[]` names each offending key. + +- 3ba8d77: fix(actions): dispatch on the declared action `type` over REST — flow actions are no longer MCP-only (#3915) + + `POST /api/v1/actions/:object/:action` had **no action-type branching at all**. + Whatever an action declared, the route went straight to `ql.executeAction` — the + script-handler registry — while the MCP `run_action` bridge had implemented the + `flow` branch since #2849. The spec is unambiguous that every non-`script` type + dispatches on `target` (`packages/spec/src/ui/action.zod.ts`), so a REST/SDK + caller who followed it and invoked a `type: 'flow'` action got + + ``` + Action '' on object '*' not found + ``` + + and had to know, out of band, to call `POST /api/v1/automation/:target/trigger` + itself. Worse for the Studio-authored case: `resyncAuthoredActions` deliberately + registers **no** handler for a flow-typed action ("no body (target/flow/url + action)"), so there was never anything for the registry to find. + + The two headless surfaces now share one dispatch: + + - **`flow`** → `automation.execute(action.target, …)` via the new + `dispatchFlowAction`, which the MCP path now calls too. The caller's identity + (`userId` / `positions` / `permissions` / `tenantId`) is forwarded, so a + `runAs: 'user'` flow enforces RLS as the invoker instead of falling into the + user-less UNSCOPED path (ADR-0049). A flow action on a kernel with no + automation service reports **503**, not a `{ success: false }` body. + - **`script`** → the handler registry, unchanged. An action with no resolvable + declaration is handler-only by definition and keeps that path. + - **`url` / `modal` / `form` / `api`** → **400** naming the type and the + prescription (for `api`, the `target` endpoint to call directly) instead of a + registry miss that reads like the action does not exist. + + The route also resolves **standalone declarations** now — `defineAction` + artifacts in the ObjectQL registry and Studio-authored `action` metadata rows, + neither of which appears inside any object's `actions[]`. They were invisible + to this route before, which is why a flow-typed one could not be dispatched — + and, separately, why its `requiredPermissions` were declared-but-unenforced on + REST while MCP honoured them. The ADR-0066 D4 gate still runs **before** the + type check, so an unauthorized caller learns nothing about how an action + dispatches. + + **Migration:** a caller invoking a `url`/`modal`/`form`/`api` action through + this endpoint used to receive `{ success: false, error: "Action '' on object +'*' not found" }` (HTTP 200) and now receives a 400 that says what to call + instead. No spec-faithful action changes behavior. + +- 4be9d99: fix(runtime,hono,plugin-dev): retire the dispatcher's `/storage` bridge — it never spoke the storage contract (#4087) + + `POST /api/v1/storage/upload` and `GET /api/v1/storage/file/:id` were a + dispatcher-side bridge to the `file-storage` service slot, written against a + service shape that does not exist: + + - **Upload** called the contract's `upload(key, data, options?)` as + `upload(file, { request })` — the parsed file object landed in the `key` + slot and `{ request }` in `data`. That is a `TypeError` against every + implementation in the repo (`S3StorageAdapter`, `LocalStorageAdapter`, + `SwappableStorageService`, plugin-dev's in-memory one), not a + near-miss: `Buffer.from({}) → ERR_INVALID_ARG_TYPE`, or an object used as + an S3 object key / `path.join` segment. + - **Download** branched on `result.url` / `result.redirect` / `result.stream` + / `result.mimeType` while the contract's `download(key)` resolves a + `Buffer`, so every branch fell through and the route answered a + JSON-serialized Buffer. + + Both routes are removed, along with `HttpDispatcher.handleStorage()`, the + `/storage` domain registration, the dispatcher-plugin mounts and the two route + ledger rows. + + **Migration.** There is nothing to migrate off in practice — neither route + could complete a request. (They were reachable: `service-storage` mounts + `/storage/upload/presigned`, not `/storage/upload`, so nothing shadowed them. + They simply had no caller — no SDK method builds those URLs.) + `/api/v1/storage` is `@objectstack/service-storage`'s surface and always was + the working one: + + - Upload — FROM `POST /api/v1/storage/upload` TO the presigned protocol + (`POST /storage/upload/presigned` → direct `PUT` to the returned URL → + `POST /storage/upload/complete`), or `client.storage.upload(file)`, which + runs all three steps. + - Download — FROM `GET /api/v1/storage/file/:id` TO + `GET /storage/files/:fileId/url` (`client.storage.getDownloadUrl(fileId)`) + for a signed URL, or `GET /storage/files/:fileId` for a stable browser URL + that 302s to it. + + Install `@objectstack/service-storage` to get those routes; without it + `/api/v1/storage` now has no handler, which is the same answer every other + uninstalled capability gives. + + Two follow-on corrections keep `declared === enforced`: + + - `@objectstack/hono` no longer mounts `app.all('/storage/*')`. That + wildcard claimed the whole `/storage` subtree for the two dead routes, so + every other path under it — service-storage's protocol above all — got the + bridge's own 404 rather than falling through. Storage is ordinary catch-all + traffic now. + - Discovery keeps gating `routes.storage` on `isServiceServeable` — the shared + `handlerReady` predicate #4058 step 2 introduced — and plugin-dev's in-memory + implementation now self-declares `handlerReady: false`. #4058 deliberately + left that one serving because the `/storage` bridge was still there to serve + it; with the bridge retired nothing routes HTTP to that slot, so `false` is + the honest value — the position `realtime` has held since ADR-0076 D12. The + implementation keeps working for in-process callers; it is simply no longer + advertised as a reachable HTTP capability. + +### Patch Changes + +- bc35e00: fix(runtime): action bodies execute under a real execution context — every owner-scoped write no longer dies FORBIDDEN + + An action body's `ctx.api` was never bound. The sandbox's `buildSandboxApi` + walked its whole fallback chain — no `actionCtx.api`, and the raw `ObjectQL` + engine has no `.object()` (that lives on `ScopedContext`, reachable only via + `engine.createContext()`, which the action path never called) — and landed on a + repo facade that proxied every call to the engine with **no `context`**. + `ctx.engine` had the identical hole. + + Context-less is not "trusted", it is **identity-less**, and identity-less is + strictly worse than either coherent posture: plugin-sharing's write gate + short-circuits on `!context.userId` (no user to own the record) and its bypass + needs `context.isSystem` (never set). So a `type: 'script'` action whose body + called `ctx.api.object('crm_case').update(...)` failed with + `FORBIDDEN: insufficient privileges to update crm_case` — **as the built-in + admin** — while the `[action-audit]` line on the same request announced + RLS-bypassing TRUSTED execution. Objects with a `public` sharing model, no + owner field, or a bypass listing passed the gate early, so only _some_ actions + broke and the defect read as object-dependent flakiness. + + Both dispatch paths (REST `/actions/:object/:action` and MCP `run_action`) now + bind `ctx.api` to `engine.createContext(...)` and thread the same envelope + through `ctx.engine`, matching what hook bodies already get from the engine's + `buildHookApi`. The envelope is the caller's `ExecutionContext` elevated with + `isSystem: true` — the posture the action surface already documents and gates + for at invoke time (the ADR-0066 D4 capability gate and the `ai.exposed` gate + are what admit a body to trusted execution). The caller's fields are spread + first, so a body's writes stay attributable (`userId` stamps + `created_by`/`updated_by`), org-scoped (`tenantId` stamps the org column and + drives driver-level tenant isolation), and joined to an open transaction — + rather than the unattributable, org-less rows a bare `{ isSystem: true }` would + write. + + No authoring change is required: `ctx.api.object(name)` inside a `body` now + does what the docs always said it does. Bodies that worked before (public / + owner-less objects) are unaffected apart from their writes now being correctly + attributed and org-stamped. + +- 48fcf70: **[ADR-0110 D5] The action-governance inventory moves to the engine plugin — + AppPlugin never ran it on the platform's own dev path.** + + Dogfooding the inventory with a positive control (an injected undeclared + handler) showed the `kernel:ready` hook it hung on never fired under `os dev`: + AppPlugin is registered conditionally (`serve.ts` skips it when the host wraps + itself; the dev fast path loads apps without it), so the checklist that + justifies D3's no-opt-out refusal was never printed where an upgrade most + needs it. + + - The addressing vocabulary (`GLOBAL_ACTION_OBJECT_KEY`, + `actionHandlerObjectKeys`, `isObjectLessActionKey`, + `resolveActionHandlerKeys`) and the reconciliation move into + `@objectstack/objectql` — the engine owns the map they describe, and the + dependency direction (runtime → objectql) permits no other home. + `@objectstack/runtime` re-exports them unchanged, so dispatch, the MCP + bridge and existing importers keep reading ONE implementation. + - `ObjectQLPlugin` now runs the inventory in its existing `kernel:ready` + handler — after `resyncAuthoredActions`, so the audited registry is final — + and again on `metadata:reloaded`, fingerprint-suppressed so a reload that + changed nothing action-related logs nothing. A Studio edit that orphans or + binds a handler updates the report live; the old boot-only snapshot went + stale on the first edit. + - Verified end-to-end with a programmatic kernel: the injected orphan is + named, a clean registry is silent. The `os dev` / `os serve` consoles still + swallow ALL plugin boot logs (pre-existing, tracked separately) — on those + surfaces the inventory becomes visible once that sink is fixed. + +- 0ecc656: feat(lint): an action body's discarded `ctx.record` write warns at author time (#4345) + + `#4344` deliberately left `ctx.record` alone, and said why: an action's + `ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that + `boundActionHandler` never writes back — the hook path's + `applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …` + is discarded for **declared and undeclared fields alike**. Reporting that + through the unknown-field rule would have been actively wrong: flagging only + the undeclared half implies the declared half persists, which is the false + completion this rule family exists to stop manufacturing. It needed its own + finding, and now has one. + + **New rule — `action-record-write-discarded` (advisory).** + + **It is not "flag every `ctx.record.` assignment"** — that would be a + false-positive machine, because mutating the snapshot to build a payload is a + legitimate idiom: + + ```js + ctx.record.stage = "won"; + await ctx.api.object("crm_deal").update(ctx.record); // the write is LIVE + ``` + + So the finding requires the write to be **provably dead**: reported only when + `ctx.record` never escapes the body as a value. Property reads + (`ctx.record.id`) do not rescue a write and do not suppress the finding; + handing the object to anything — an argument, an assignment RHS, a spread, a + return — does. Aliasing (`const r = ctx.record`) reads as an escape, which is + the safe direction: it costs a missed finding, never a false one. + + Truthiness and type tests are **not** escapes, and that distinction is what + makes the rule fire on real code rather than almost never. Running it against + the showcase app is what surfaced it: `mark_done` opens with + `ctx.recordId || (ctx.record && ctx.record.id)`, the defensive idiom action + bodies are actually written with, and counting that guard as an escape silenced + the finding on the one body in the repo that had a record write. A test reads + the reference and yields a boolean — or, for `&&`/`||`/`??`, yields the left + operand only when it is falsy, which is null or undefined and persists nothing. + Only the LEFT operand is a test: `x || ctx.record` really does evaluate to the + object, and still escapes. + + **One suite member, two rule ids.** Both findings fall out of one parse of one + source on one surface, so `validateActionBodyWrites` reports both rather than + `REFERENCE_INTEGRITY_RULES` growing a second member that would parse every + action body again to say two things about the same walk. The alternative — + hand-wiring it into the three CLI commands — is the drift that suite exists to + end, and `validateReadonlyFlowWrites` is the standing proof: wired into + `validate` and `compile`, never into `lint`. The trade-off is written down at + both ends rather than left to be rediscovered. + + **The ledger ratchet fired, as designed.** `record-property-assign` joins the + shared `HOOK_BODY_WRITE_PATTERNS` — the extractor's shape inventory, not any + one rule's — and both existing consumers had to classify it before it could + land. That was not cosmetic on the hook side: a `record-property-assign` write + carries no `object`, and `validateHookBodyWrites` branched on exactly that to + mean "a `ctx.input` write", so the new shape would have been reported as _"the + hook writes 'stage' to its input"_. The hook rule now declares its own + consumed subset (`HOOK_BODY_WRITE_PATTERN_IDS`) and its exclusion with a + reason — a hook sandbox context has no `ctx.record` at all + (`buildSandboxContext` never sets it), so the expression throws at run time + rather than silently no-op'ing, and a loud failure is not an advisory rule's + business. + + `extractHookBodyWriteSet` is the new one-parse entry point, returning the + writes plus the `ctxRecordEscapes` signal; `extractHookBodyWrites` stays as a + thin projection of it. + + **Boot path.** The action gate's prefilter widens from `api` to `api`-or- + `record`, so a body reaching neither still never loads the ~9 MB TypeScript + compiler. `lazy-deps.test.ts` pins it — and its header and two case names, + which still claimed every lazy dep waited on "a react page", now say which + trigger each one pins (typescript has also been loaded by the hook-body gate + since #4271). + + `@objectstack/spec` / `@objectstack/runtime`: `ScriptBodySchema`, + `ActionSchema.body` and `ScriptContext.record` now state that + `ctx.api.object(...)` is the only path that persists anything, and that + `ctx.record` is read-only in effect. Doc comments only — no schema or + generated-artifact change. Whether the runtime should instead refuse or honour + a record write stays open on #4345. + +- 0c90ece: fix(actions): the object-less `POST /actions//:action` shape is actually reachable over HTTP (#3913 follow-up) + + #3913 taught `handleActionsRequest` to route a single-segment path — the + object-less shape `POST /api/v1/actions//:action` — at the canonical `'global'` + key. That code was correct and unit-tested, and **unreachable**: the dispatcher + mounts its routes explicitly, `:object` does not match an empty path segment, + and no registration covered the `//` form. Over real HTTP the request fell + through to Hono's `notFound` and answered a bare `{error: 'Not found'}` with the + actions domain never running — so the exact URL #3913 was filed against still + did not dispatch. + + The tests could not catch it because they call `dispatcher.handleActions()` / + `dispatcher.dispatch()` directly, bypassing the route table. This is the same + class of bug `dispatcher-plugin.routes.test.ts` was created for after `/mcp` and + `/keys` shipped the same way; the guard now covers the action routes too. + + Found by dogfooding the running showcase app, not by the suite. + + `POST /api/v1/actions//:action` now answers identically to + `POST /api/v1/actions/global/:action` — same envelope, same `'global'` key. The + object-scoped registrations are untouched and unshadowed (Hono matches the + literal `//` without competing with `:object/:action`). + +- 6fa1827: fix(runtime): the `/ai/agents` degraded fallback answers in the declared envelope (#4053) + + `GET /ai/agents` was the last unenveloped SDK-addressable route. The framework's + degraded fallback — what an open-source runtime with no `service-ai` answers — + now returns `{ success: true, data: { agents: [] } }` via `deps.success`, and the + route-envelope guard's last ratchet retires with it: **0 ratcheted on both + surfaces.** + + ## Why `data: { agents }` and not `data: []` + + #3983 set the precedent that `data` carries the payload directly, and following it + here would have looked consistent. It would also have been wrong, and silently so. + + `AiAgentsResponseSchema` is a **declared** payload schema; share-links' `{ links }` + was an ad-hoc wrapper with none. So this is the #3843 relocation — the declared + payload moves under `data` unchanged, the way `SettingsNamespacePayload` did — + rather than a reshape. + + That distinction decides the blast radius. `unwrapResponse` returns `body.data` + when a body has a boolean `success` **and** a `data` key, so: + + | conversion | `client.ai.agents.list()` | + | ----------------------------- | ----------------------------------- | + | `data: { agents }` (this one) | reads `.agents` off it — **works** | + | `data: [...]` (flattened) | `.agents` is `undefined` → **`[]`** | + + An empty list is not a visible failure on this route. `useAiSurfaceEnabled` gates + the entire AI surface on `agents.length > 0`, and an empty catalog is the _correct_ + answer for a seat-less user (ADR-0068) or a Community-Edition deployment. The + broken state and the legitimate one are indistinguishable — no error, no 403, no + log. + + ## Consequence: no lockstep + + Because the SDK reads both shapes identically, **each surface converts on its own + schedule**. Cloud's `service-ai` still answers unenveloped and keeps working + unchanged; objectui already reads all four shapes (objectui#2992). The + "three repos in one batch" framing #4053 opened with does not apply to this + variant. + + Five tests in `@objectstack/client` pin it, including the road not taken: the + flattened body asserts `[]`, so the cost of choosing it is recorded rather than + rediscovered. + +- 05154a1: Discovery stops telling Cloud/Enterprise deployments that nothing implements `ai` (#4093 follow-up). + + `CORE_SERVICE_PROVIDER` recorded `null` for `ai` because no **workspace** package provides it, and `serviceUnavailableMessage('ai')` therefore produced _"No implementation ships for the 'ai' slot"_. That is false: `@objectstack/service-ai` registers the slot in `objectstack-ai/cloud`. The table conflated "not in this repository" with "does not exist" — the same class of wrong answer the table was introduced to end, one step further out. + + Verified against the cloud repository rather than inferred: `packages/service-ai/src/plugin.ts` calls `ctx.registerService('ai', …)`, and the package is `private: true`, so there is genuinely nothing to install — which is why `null` stays right and an `Install X` sentence would still be wrong. `search`, `workflow` and `graphql` were checked the same way and nothing registers them in either repository, so their `null` and their "nothing ships" sentence are accurate. + + `ai` now carries a `REMEDY_DETAIL` sentence — _"Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework"_ — the mechanism `ui` already used. Both discovery (`services.ai.message`) and the `/ai` 501 body report it. + + This **removes** code: `/ai`'s domain had a local message override, added because the shared sentence was wrong there. Correcting the table fixed the domain _and_ discovery, which the override could never reach, so the override and `capabilityUnavailable`'s `message?` parameter are both gone. A slot whose sentence is wrong needs the table corrected, not a local exception. + + Also corrected: three places still describing `/ai`'s absent-service answer as a 404 (`docs/api/client-sdk.mdx`, `docs/releases/v17.mdx`, and a comment in `packages/client`'s URL-conformance test) — stale since that answer became 501. + + FROM → TO: `services.ai.message` and the `/ai/*` 501 body change text. Nothing branches on either — `status` and `enabled` are the contract, the message is prose for humans and agents. + +- fce14ab: fix(runtime): the `callData('query')` ObjectQL fallback serves the caller's query instead of dropping it (#4386) + + When the protocol service is unavailable (lean assemblies, MCP multi-env with + a raw driver), the fallback passed only `{ context }` to `ql.find` — the + caller's `where`/`orderBy`/`limit` never left the function, and the ENTIRE + table came back as an ordinary-looking `{ records, total }`. The sibling + `get`/`update`/`delete` fallbacks all built a proper `where`; `query` was the + only verb whose fallback forgot the request. + + The fallback now forwards the canonical QueryAST keys both possible + recipients execute (`where`, `fields`, `orderBy`, `limit`, `offset` — engine + option bag and raw-driver QueryAST are aligned by design), drops a + caller-supplied `context` (server-derived only, matching `findData`), and + refuses with 501 anything it cannot reproduce without the protocol layer — + wire spellings needing fold/lowering (`sort`, `select`, `skip`, `populate`) + and capabilities a raw driver would silently drop (`search`, `expand`). The + protocol path is unchanged and keeps accepting wire spellings. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7309c81: fix(runtime,cli): `projectRoot` reaches the metadata repository; stop compiling tests into the CLI's dist (#4065) + + Two defects behind the last of #4065's stray `.objectstack/` directories — the + one under `packages/cli/`. Neither is cosmetic. + + **1. `projectRoot` only got half the stack.** `createStandaloneStack`'s + `projectRoot` is documented as scoping a boot's on-disk state to the project + folder "so different examples / apps don't share a single database by accident", + and it did redirect the default sqlite database. But it was never passed to + `MetadataPlugin`, whose `FileSystemRepository` kept rooting at `process.cwd()`. + So one "project root" meant two different directories: a boot pointed at project + A wrote `A/.objectstack/data/` and `/.objectstack/metadata/`. It now + forwards `rootDir`, and `bootSchemaStack` accepts a `projectRoot` to pass down + (defaulting to `process.cwd()`, which is right for every real `os migrate` — the + CLI runs from the project directory). The two migrate integration suites, which + build a fixture project in a tempdir, now scope their boots to it. + + **2. The CLI compiled its own tests into `dist/` — and vitest ran them.** + `tsconfig.build.json` included all of `src` with no exclude, so every + `src/**/*.test.ts` was emitted as `dist/**/*.test.js`. Two consequences: + + - `files: ["dist"]` **published** them. + - This package has no vitest config, so `vitest run` collected the compiled + copies alongside the sources: **81 test files and 849 tests where the sources + hold 58 and 581**. Every `src/` test also ran as a stale `dist/` twin built + from whatever the source said at the last build. + + That is not just noise — it silently defeats edits. A fix to a source test + appeared not to work, because the run was still executing the pre-fix compiled + duplicate; that is exactly how the `.objectstack` residue survived a correct + fix long enough to look like a different bug. It also means a source test could + be edited to pass while its stale twin kept asserting the old behaviour, and + neither would be obviously wrong. Test files are now excluded from the build. + + No other package is affected: the rest build with `tsup`, which emits only + declared entry points. Verified by scanning every `packages/*/dist` for + `*.test.js` — the CLI was the only hit. + +- 41dcda3: fix(spec,runtime,service-automation): `IAutomationService` declares the connector registry it already serves (#4127) + + The fourth and last of the dispatcher call sites #4127 found calling a method its + contract never declared. The first three shipped in #4143; this one was held back + because the fix is a **type move**, not a type addition — `ConnectorDescriptor` + was declared in `@objectstack/service-automation`'s engine, which is one + _implementation_ of `IAutomationService`. A contract cannot name a type that + lives inside its own implementation, so `getConnectorDescriptors` could not be + declared at all until the type had a home in the spec. + + **`IAutomationService` += `getConnectorDescriptors?()`.** It is the sibling of + `getActionDescriptors`, which the contract has declared since ADR-0018: the two + fill the flow designer's `connector_action` node together — node vocabulary from + one, the connector → action → input pickers from the other. Only one of them was + written down. `GET /api/v1/automation/connectors` has served the other since + ADR-0022 by probing for the method and then re-typing its own result as `any` to + filter on `?type=`, which is a filter on a field the type system did not know + existed — one typo from silently matching nothing and answering an empty + registry, which is also what this route legitimately returns when the method is + absent, so the failure had no distinguishable symptom. + + Optional for the same reason `getActionDescriptors` is: a connector registry is a + capability of the flow-engine implementation, not a property of every automation + slot. A script-runner filling the slot has no connectors to describe, and the + route answers an empty registry rather than a 404 — the `handlerReady` posture + does not apply, since the slot is serveable and only this capability is absent. + + **`ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / + `ConnectorState` move to `@objectstack/spec/integration`**, beside the ADR-0097 + provider contract, for the reason that file already states about itself: they are + pure types, so a connector plugin — or a designer client, or the dispatcher — + speaks about registered connectors depending only on the spec, with no runtime + coupling to the engine. `ConnectorOrigin` is ADR-0097 §4 vocabulary and + `ConnectorState` is #3017 vocabulary; neither was ever engine-private in meaning, + only in location. + + Nothing is renamed and no shape changes. `@objectstack/service-automation` + imports the four back and re-exports them from its index — the same names, from + the same entry point — so every existing importer compiles unchanged. + `ConnectorState` joins that re-export, which it should have been in all along: it + is a required field of the descriptor the index has always exported. + + **The test fixture had already drifted, which is the concrete cost.** The + dispatcher's connector mock declared `{ name, label, type, actions }` and omitted + `origin` and `state` — both **required** on `ConnectorDescriptor`, and both the + fields a designer reads to tell a live declarative instance from a plugin one + (ADR-0097 §4), or a dispatchable connector from a degraded one that is listed + honestly rather than hidden (#3017). Nothing caught it, because an undeclared + return type cannot be checked against. The fixture is typed now, so it cannot + drift again, and a new test pins that `origin` / `state` / `degradedReason` + survive the hop through the route rather than only `name` and `type`. + + Verified: `@objectstack/spec` **7089 tests / 272 files** (2 new contract tests), + `@objectstack/service-automation` **457 / 41**, `@objectstack/runtime` + **218 http-dispatcher tests** (1 new), `tsc --noEmit`, `pnpm lint`, the liveness + and empty-state gates, and the three generated-artifact gates — all clean. + +- a225ef5: fix(runtime,webhooks): the path object wins on /data/:object/query, and the webhook envelope owns its keys (#3946) + + Follow-up sweep for the shape behind #3897 and #3933 — a trusted, server-derived + value written into an object literal with a caller-controlled bag spread OVER + it. Both of those were in the same block of REST code, so the pattern was swept + across all 1313 non-test TypeScript files in `packages/`. Nine candidate sites; + one real, one worth hardening, seven verified clean (recorded in #3946 so the + next sweep does not re-litigate them). + + **`POST /data/:object/query` (runtime dispatcher).** The `/data` domain built + `{ object: objectName, ...body }`, so `{"object":"other", …}` in the body moved + the read to a different object than the URL named. + + This is NOT an authorization bypass, and the tests pin why: `callData` gates + API exposure on `params.object`, so the gate followed the body and agreed with + the read — an object hidden by `apiEnabled: false` was refused either way. What + broke is that the URL stopped describing the operation (audit trails, logs, and + anything keyed on the request path saw object A while object B was read), and + that one endpoint spoke a second dialect of the contract the REST side had just + standardised on: the path object wins. The other handlers in that file never had + the problem — they nest caller data (`data: body`, `query: normalized`) instead + of splatting it, and the GET-by-id branch already allowlists its query params + against exactly this pollution. + + **Webhook delivery envelope.** `auto-enqueuer` built + `{ object, recordId, action, timestamp, ...payload }`, letting an event payload + rewrite the envelope a subscriber receives. Behaviour-neutral for the engine's + own publishers — `data.record.*` payloads are `{ recordId, after, changes }` + with record fields nested under `after`, so none of those four keys collide + today — but the shape was wrong, and the `payload.id` fallback right above it + suggests publishers that flatten record fields do exist. Envelope keys are + written last now. + +- c20b875: **Correct the stale premise left behind by #4012: the degraded-boot stderr copy + survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.** + + `emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to + stderr in addition to `logger.warn`, and every comment and test name explaining + why cited the same reason: `os serve` swallowed all of stdout while the kernel + booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window + now buffers and replays `warn`-and-above — which retires the _stated_ + justification for a duplicate that is nonetheless still load-bearing: + + `Logger.write()` returns before touching a stream when the record is below + `config.level`, so at `--log-level error`, `fatal` or `silent` the banner's + `logger.warn` reaches **no** stream at all. A production host at `error` is + exactly the deployment this escape hatch exists for, and exactly where a + logger-only banner would vanish. Removing the stderr copy on the strength of + #4012 would therefore have been a regression — so this documents the reason that + is still true, in the places someone would read before deleting it: + `degraded-boot.ts`, the engine's emit site, and all three parity tests + (objectql, runtime, service-datasource), which are renamed off "which `os serve` + boot-quiet cannot swallow" to "which the operator log level cannot filter away". + + The objectql parity test now proves the claim instead of asserting around it: it + drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on + stderr _and_ nothing on stdout. Set the level to `warn` and it fails — so the + test is pinned to the level filter rather than passing for any reason. + + Also corrected in the same sweep, all comment-only, all previously overstating + what #4012 had not yet fixed: + + - the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the + boot window swallowed the engine's binding warnings. Its real justification is + stronger and unchanged: a flow that silently fails to arm emits **no** log line + at any level, so binding state has to be read off the live engine — absence of + a warning was never evidence of a bound flow. + - the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s + seed-outcome note attributed the silence to the boot window; the operative + gate is that `SeedLoader`'s result logs are `info`, under the default `warn`. + + No behavior changes. + +- 0373d52: Both discovery builders now derive the `data` service entry from the implementation in the slot, closing the hardcoded "kernel-provided" block (#4130). + + #4089 computed `metadata`; `data` was the last entry that judged itself, reporting `status: 'available'` and `handlerReady: true` unconditionally. That was true — but by a convention in a different package, not by anything either builder checked: ObjectQL is the slot's only producer, and plugin-dev always loads `ObjectQLPlugin` as a child, so plugin-dev's `data` stub (`find()` returns `[]`, `insert()` mints an id and stores nothing) never reaches the slot. A second producer, or a trimmed dev config, and the hardcode starts lying about the platform's most load-bearing capability. + + Both builders now read the registered service's `__serviceInfo`: + + - a real engine carries no marker ⇒ `available` + `handlerReady: true`, byte-identical to the hardcode it replaces (verified on a real kernel boot); + - a self-declared stub ⇒ its own `status` and `message`, with `handlerReady: false` (the default for `stub`), so a consumer that gates on `handlerReady` stops treating an empty query engine as a real one. + + `handlerReady` is derived here rather than pinned `true` as it is for `metadata`, because the two routes differ: `/meta` answers from the protocol whatever fills the metadata slot, while `/data` needs the `protocol` or an objectql-shaped service and 503s without them — and the only stack where a stub occupies the `data` slot is one where ObjectQL never registered. No routing, gating or dispatch behavior changes: the `data` domain resolves its engine directly and never consulted this slot. + +- 4f30943: Both discovery builders now compute the `metadata` service entry from the implementation that fills the slot, instead of hardcoding opposite verdicts for it (#4089). + + `metadata` sat in a "kernel-provided (always available)" block above the loop that reads `__serviceInfo`, hardcoded separately in each builder — and the two disagreed about the same slot: + + - `@objectstack/runtime`'s dispatcher declared it permanently `status: 'degraded'` with `message: 'In-memory registry; DB persistence pending'`, so a stack with `MetadataPlugin` and a real `sys_metadata` table was still reported as having no persistence. + - `@objectstack/metadata-protocol` declared the same slot permanently `status: 'available'`, so the kernel's in-memory fallback (`createMemoryMetadata`, auto-registered when no metadata plugin is present) read exactly like a persisted registry — the `__serviceInfo` marker #4058 gave it went unread here. + + Both now read the registered service's `__serviceInfo` (via `readServiceSelfInfo`) and report what it declares: + + - kernel in-memory fallback, or plugin-dev's dev registry → `status: 'degraded'` plus that implementation's own `message`, which names what is missing and what to install. + - `MetadataPlugin` (or any implementation carrying no marker) → `status: 'available'` with no message. + + `handlerReady: true` is now stated unconditionally on both sides: it answers "is `/api/v1/meta` mounted?", and that route is served by the protocol whichever implementation occupies the slot — a degraded service in it does not unmount the route. Nothing about routing, gating, or dispatch changes; consumers that treat `status` as a capability claim (AI agents, the console) simply stop being told two different things by two hosts. + +- 86a71d1: Discovery's "install this to enable" now names a package that exists (#4093 follow-up). + + Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name. + + The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist** — `plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`. + + A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". + + `CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name. + + Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one. + + `scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind. + + FROM → TO: `services..message` and `services..provider` change text for most unavailable slots. Anything matching on the old `Install a plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents. + +- d5c75e2: fix(spec,runtime,service-i18n): the dispatcher domains and their service contracts describe the same surface (#4127) + + #4087 retired a `/storage` bridge that called `upload(key, data, options?)` as + `upload(file, { request })` — a shape no implementation has. Sweeping the other + dispatcher domains against `packages/spec/src/contracts/*` found the mirror-image + gap in three places: the call site and the implementation agreed, and the + **contract** was the thing that had never been written down. Each one was worked + around at the call site with `typeof x.foo === 'function'` — a duck-type is what + "the contract does not cover this" looks like when nobody fixes the contract. + + Fixed at the contract, per Prime Directive #12. + + **`INotificationService` — the inbox half.** `listInbox` / `markRead` / + `markAllRead` now exist, with `InboxQuery` / `InboxNotification` / + `InboxListResult` / `MarkReadResult`. Three SDK-expressed routes + (`notifications.list` / `.markRead` / `.markAllRead`) have rested on them all + along, implemented by `service-messaging`, while this contract described only + `send`. The cost was not theoretical: the dev notification stub implements + exactly `send` and `sendBatch` **because it followed the contract**, so the one + implementation written to spec was the one the dispatcher had to duck-type past. + + They are optional, and the probe stays: an inbox needs a durable store, and a + send-only provider (SMTP, Twilio, a Slack webhook) fills the slot legitimately + without one. `handlerReady` cannot express that — the slot is serveable, one + capability of it is absent. The `/notifications` domain now takes + `INotificationService` instead of `as any`, and each write route probes its own + method rather than riding the entry `listInbox` check (they are separately + optional, so "has an inbox to read" never implied "has read-state to write"). + + **`II18nService.getFieldLabels`.** Both serving surfaces — the dispatcher's + `/i18n/labels/:object/:locale` and service-i18n's own mount — probed for it and + both documented it as "optional on `II18nService`", which was not true. It is + now. service-i18n's probe loses two casts with it (one through + `Record`, one re-declaring the signature inline). + + **`IAutomationService.getFlowRuntimeStates`** + the `FlowRuntimeState` type. + `GET /automation/_status` (and the CLI boot summary, and the + `kernel:bootstrapped` audit) already called it while the contract stopped at + `listFlows(): string[]`. The dispatcher's inline cast declared it as + `{ name, enabled, bound }` — a third copy of the shape and a narrower one than + the engine returns, dropping the `status` / `triggerType` / `object` fields that + say WHY a flow is unbound. + + Two runtime fixes fell out of the same sweep: + + - **`POST /automation/trigger/:name` now builds a real `AutomationContext`.** + It passed the raw HTTP body to `execute(name, body)`, so the + `{ recordId, objectName, params }` translation never ran and — the sharper + half — no caller identity was forwarded. A flow's default `runAs` is `'user'`, + and a `runAs:'user'` run whose trigger resolved no user has its data + operations REFUSED (#3760, fail-closed), so `client.automation.trigger()` + could not run a data-touching flow at all while `POST /:name/trigger` could. + service-automation's own comment claims "most trigger surfaces (REST action / + trigger endpoint) already resolve the full envelope"; for this endpoint it was + not true. Both routes share one context builder now. + - **The dead `automationService.trigger(...)` probe is gone.** Nothing in the + repo has ever implemented `trigger` on the automation slot and the contract + never declared it, so the branch was unreachable on every deployment and its + `execute` "fallback" was the route. Declaring `trigger?` would have blessed a + second name for `execute`; the dead branch is deleted instead. + + No migration. Every added contract member is optional, so existing + implementations stay valid; the two runtime fixes only make routes that were + failing or degraded behave like their working twins. + +- bb192c4: Gate every dispatcher service domain on `handlerReady` instead of on slot occupancy (#4058 step 2). + + #4000 made the `/analytics` domain execute ADR-0076 D12's third conclusion ("consumers treat only `handlerReady: true` as a real capability"); every other domain still gated on "is a service registered", so a self-declared stub occupying `automation` / `notification` / `ai` / `file-storage` / `i18n` was called like a real implementation and its fabricated answer went out as a 200. Step 1 (#4082) made the two kinds of dev implementation distinguishable; this is the gate that reads the distinction. + + - The `/analytics`, `/automation`, `/notifications`, `/ai`, `/storage` and `/i18n` domains, the route-mount gate, discovery's `routes`/`features`, and the metadata-protocol builder's route advertisement now share one predicate (`isServiceServeable`): a slot whose occupant self-declares `handlerReady: false` is answered exactly as an empty slot is — the domain's existing 404, or the explicit 501 `/storage` and `/i18n` use. One predicate, so what is advertised and what is served cannot disagree. + - `handlerReady`, not `status`, is the test. An implementation that declares `degraded` defaults to `handlerReady: true` and keeps serving — which is why the in-memory `file-storage` and `i18n` implementations are unaffected. + - `discovery.services.*` stays presence-gated: a registered stub still reports `{ enabled: true, status: 'stub', handlerReady: false }` (with no `route`), which says strictly more than collapsing it to `unavailable` would. + - `/ai` improves for the stub case: an occupied-but-unserveable slot used to fall through to a 503 "AI service routes not yet initialized" and lose the `GET /ai/agents` empty-list answer the console polls for on every navigation. Both are restored. + + No change for a host whose services are real implementations. If you register your own stub under one of those six slots and relied on the dispatcher calling it, either drop the `handlerReady: false` self-declaration (declare `degraded` if it genuinely serves) or install the real service. Not gated, deliberately: `/data`, `/meta`, `/auth` and the security path — their dev stubs back the dev stack's own core loop, and gating them would 404 the dev stack itself. + +- 98e7cc7: fix(runtime): dispatcher error exits serve VALIDATION_FAILED as 400 with `fields[]` (#3918) + + `ValidationError` — what objectql's record and rule validators throw — carries + `.code = 'VALIDATION_FAILED'` and `.fields[]`, one entry per offending field. It + deliberately carries no `.status`, no `.statusCode`, and no `.issues`: it is a + plain domain error, and deciding it means "400" is the HTTP boundary's job. + `@objectstack/rest` has always done that (`mapDataError` → 400 with `fields[]`). + The runtime dispatcher's two error exits did not, because each read exactly the + properties this error lacks: + + - **`HttpDispatcher.errorFromThrown`** (the RETURNED-error path — `/meta` save, + `/packages` publish, …) fell back to the caller's `fallbackStatus` for want of + a `.status`, and built its structured `details` from `.issues` alone, so + `fields[]` was dropped. + - **`dispatcher-plugin`'s `errorResponseBase`** (the THROWN-error path — every + route the plugin mounts: `/analytics`, `/packages`, `/i18n`, `/storage`, + `/automation`, `/auth`, `/notifications`, `/mcp`, …) took the same 500 + fallback, and its body was only `{message, code}`. Landing on 5xx then dragged + the message through the #3867 leak sanitiser, so a user typing a bad email + address got back a **500 "Internal server error"** — no status a client could + act on, no message worth showing, and nothing to attach to the input. + + Both exits now recognise the shape and answer the way rest-server does: **status + 400**, with the error's `fields[]` passed through verbatim in `details` + alongside `code: 'VALIDATION_FAILED'`. Any surface the dispatcher serves can + therefore highlight the specific field the user got wrong, the way a form served + by `/data` already could. + + Matched by duck-typing on `code === 'VALIDATION_FAILED' || name === +'ValidationError'` — the same both-ways predicate `mapDataError` uses — so a + hook or service that throws `{ code: 'VALIDATION_FAILED', fields }` by hand is + served identically, and the runtime takes no dependency on objectql. An explicit + `.status` / `.statusCode` on the error still wins: 400 is supplied only as the + fallback that was previously 500. Non-validation errors are untouched — same + status, same message sanitising, same `details`, and `errorResponseBase` still + emits the exact two-key body it always did. + +- 4cf7c61: fix(runtime): route every domain `catch` through `errorFromThrown` so status and `fields[]` survive (#3918 follow-up) + + #3867 taught `dispatcher-plugin`'s `errorResponseBase` to read an error's + `status` (not just `statusCode`), and #3918 taught + `HttpDispatcher.errorFromThrown` the `VALIDATION_FAILED` shape. Both fixes were + invisible to a whole tier of handlers underneath them: the domain modules each + caught their own errors and called `deps.error(e.message, e.statusCode || 500)` + directly, bypassing `errorFromThrown` entirely — 13 call sites, 9 of them in + `/packages` alone. + + The consequence on `/packages`, `/meta/_drafts`, `/ui`, `/security` and the + `/mcp` transport: + + - **A deliberate status was downgraded to 500.** Every protocol-layer domain + error in this codebase carries its HTTP status as `status`, not `statusCode` + (`OBJECT_NOT_FOUND`, `RECORD_NOT_FOUND`, `CLONE_DISABLED`, plugin-sharing's + `FORBIDDEN`, …) — the exact read #3867 fixed one tier up. So a 404 these + routes meant to return arrived as a 500, and the message was dragged through + the 5xx leak sanitiser on the way out. + - **A `ValidationError` still lost its `fields[]`** and its 400, re-opening + #3918 on precisely the routes it was filed against. + + Every one of those catches now calls `deps.errorFromThrown(e, …)`, so both + fixes finally reach the routes that need them. Deliberate per-route fallbacks + are preserved rather than flattened to 500: the `/meta` save fallback keeps + **501** (that branch is reached only when the protocol has no `saveMetaItem`, + so "unsupported" is the honest default) and the `/meta` two-part lookup keeps + **404** — but a validation failure on either now answers 400 with its fields + instead of being swallowed by the fallback. + + `domains/keys.ts` is deliberately **not** converted: it discards the underlying + error on purpose, because the message could echo row contents. Its literal + `'Failed to create API key'` is the correct answer there and stays. + + No behaviour change for errors that already carried `statusCode` — that read is + preserved, only widened. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- 385c4b0: fix(actions): seed a flow action's params with the row id, like the trigger route does (#3915 follow-up) + + #3915 gave the REST `/actions/:object/:action` route its flow dispatch and + documented it as "equivalent to `POST /api/v1/automation/:target/trigger`, + without having to know the flow name". A real run showed that claim did not + hold: the params bag carried the subject record's fields — so `id` — but never + `recordId`. The CRM's own `crm_convert_lead` action declares + `recordIdParam: 'recordId'` and its flow reads `{recordId}`, so invoking it + through the actions endpoint reached the automation engine and then died at its + first node: + + ``` + Flow 'crm_convert_lead_wizard' failed: Node 'get_lead' failed: get_record: + refusing to run — 1 filter condition(s) resolved to nothing … `{recordId}` (at id) + ``` + + while the identical run through `/automation/crm_convert_lead_wizard/trigger` + paused normally on its first screen. Only a live invocation surfaced it — the + unit tests mock `automation.execute`, so they pinned the call shape without + noticing the bag was missing the key flows actually read. + + `dispatchFlowAction` now seeds the row id under the same keys + `domains/automation.ts` seeds for the trigger route — `recordId` and the + `Id` camelCase alias — plus the action's own declared + `recordIdParam` (sourced from `recordIdField`, default `id`) when it names a + third key. Explicit action params still win over every seed, and the seeding + applies to the MCP `run_action` path too, which shared the same gap. A declared + `recordIdParam` that no dispatcher honoured was the `declared ≠ enforced` shape + in miniature. + +- 45dc446: Every in-memory fallback and dev stub now self-describes with the standard `__serviceInfo` descriptor, classified by what it actually is (#4058 step 1). + + ADR-0076 D12 gave services one way to say "I am not the real thing", but the producers never converged on it: + + - The kernel's own fallbacks (`createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`) carried `_fallback: true` — a marker **no** consumer recognized, `readServiceSelfInfo` included — so both discovery builders reported them as fully `available`. + - `plugin-dev` marked all of its implementations with the same `_dev: true`, normalized to `status: 'stub', handlerReady: false`. That declared a working in-memory search index exactly as fake as an AI stub returning invented text. + + Both now carry `__serviceInfo`, split by a rule that holds across the whole set: + + - **`degraded`** — really does the work, with reduced capability: `cache`, `queue`, `job`, `file-storage`, `search`, `i18n`, `metadata`, `workflow`, `realtime`. Its answers are true answers; the `message` names what is missing (no persistence, no scheduling timer, no state-machine validation, …). + - **`stub`** — the answer is fabricated: `ai`, `automation`, `notification`, `data`, `auth`, `security.permissions`, `security.rls`, `security.fieldMasker`. Never to be mistaken for a capability. + + `handlerReady: false` is set independently wherever no HTTP handler serves the slot (`cache` / `queue` / `job` / `realtime`, and every `stub`). + + Discovery output changes accordingly — a kernel fallback that used to report `status: 'available'` now reports `degraded` with an explanatory message. No routing, gating, or dispatch behavior changes: every dispatcher domain still resolves services exactly as before. Consumers reading `discovery.services.*` get the truth instead of a uniform claim. + + For anything that duck-typed the old markers: `svc._fallback` / `svc._dev` → `readServiceSelfInfo(svc)` from `@objectstack/spec/api` (the legacy `_dev` key is still understood by that reader, so third-party stubs carrying it keep working). + +- 507b92a: fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957) + + The write path built every built-in validation message by concatenating the **API + field name** into a **hardcoded English** template. Those strings are what the + Console toast, the CSV-import row report, the CLI and any custom client display + verbatim, so a Chinese-locale user importing a bad row read: + + ``` + 第 1 行:penalty_amount must be ≥ 0 + ``` + + …for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The + form layer localized the _same_ constraint correctly (the browser's native + `min`), so the language flipped depending on which layer caught the value. + + **Three things changed.** + + 1. **The message is rendered in the caller's locale** from a built-in catalog + (`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`, + `zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles. + The locale comes from `ExecutionContext.locale`, whose contract already read + "Drives message catalogs"; this is the consumer that makes that true. Both + HTTP entries (REST server, runtime dispatcher) now resolve it from the + request's `Accept-Language` / `?locale` first, falling back to the workspace + `localization.locale` — so a rejection message and the field labels around it + can no longer disagree. + + 2. **The field is named by its label, never the API name**: translation bundle + (`objects..fields..label`) → declared `label` → API name as the last + resort. `FieldValidationError.field` still carries the API name so a form can + focus the right input. + + 3. **The constraint is exposed as data**, so a client can format its own text + instead of parsing the sentence: + `{ field, code, message, label, constraint: { min: 0 } }`. This rides + ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema` + (`constraint` tightens from `unknown` to `Record`) rather + than adding a parallel payload — `label` is the only new field. The bag + carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the + message templates interpolate from exactly those keys. + + Covered end-to-end, not only in the validator: single and batch insert, + single-id and multi-row update, ADR-0113's clear-out rejection, the object-level + rule evaluator's own built-in messages (`requiredWhen`, per-option gating, + state-machine fallbacks), and the importer's cell-coercion, required pre-check + and #3956 bound pre-check messages — all of which land in the same row report. + + **What this changes for consumers.** + + - `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to + match on. Message keys are finer-grained than codes — `invalid_datetime`, + `invalid_option_value`, `required_cleared` are rendering detail and never reach + the wire — so localization never splits the client-facing vocabulary. + - `message` **text changes**: it is localized, and it names the field by label + even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything + asserting on the old English string should match `code` (and now + `constraint`) instead. + - An author-written validation-rule `message` is never touched — it is already + in the language its author chose. + - A deployment can override any built-in message with a `translation` item + defining `validation.field.` (e.g. + `validation.field.min_value: '{{label}}不得小于 {{min}} 元'`). + - The importer's reference-failure message no longer names the target object's + API name (`no sys_user matches "…"`): naming internal identifiers is the + defect being fixed, and the column plus the offending value are what an + importer can act on. + +- 99b4392: Advertise `mcp` in `/discovery` only when it is actually serveable (#4024). + + Both discovery producers gated the `/mcp` route on `isMcpServerEnabled()` alone. + The stated justification was a lockstep — `os serve` auto-loads plugin-mcp from + the same flag, so on that path advertised did imply mounted. But the lockstep is + a property of the CLI, not of the dispatcher: `@objectstack/rest` has no + `@objectstack/mcp` dependency, mounts no `/mcp` route and performs no auto-load, + so a host that embedded it without plugin-mcp advertised `/mcp` in `/discovery` + and then answered 501 on it — the `declared ≠ enforced` failure #3369 forbids, + and a broken contract for third-party clients that read `/discovery` to decide + what exists. + + Both producers now require the flag AND a serveable MCP service. The runtime + dispatcher gates on the handler's own predicate (`typeof +mcp.handleHttpRequest === 'function'`), so a wrong-shaped service can't + over-promise either. `@objectstack/rest` probes via the per-request kernel or the + single-env `serviceExistsProvider`; when it genuinely cannot probe it keeps the + prior flag-only answer rather than hiding a working endpoint (fail-open, + ADR-0057 D10). The `os serve` / `os dev` path is unchanged — it loads the plugin, + so the service resolves and `/mcp` is still advertised. + + Also exercises the `mcp: false` seam in `route-parity.integration.test.ts`, which + had existed unused since the file was written: `bootServe()` was only ever called + with no args or `{ notification: false }`. The one capability whose advertisement + was not service-presence gated was also the one whose absence was never tested. + +- 39eb01b: fix(runtime,cli,types): `os migrate` and the dev runtime now share one `__search` companion schema view (#3955) + + On a zh-locale deployment the dev runtime provisions the hidden `__search` + pinyin companion column (ADR-0098) on every eligible object, but the + `os migrate plan`/`apply` boot went through `createStandaloneStack`, which + never derived the locale-gated pinyin decision from the compiled artifact. + Its metadata therefore lacked every companion column, and `migrate plan` + reported each live `__search` column of a dev-created database as a + destructive orphan — with `--allow-destructive` as the printed remediation, + which would have dropped live feature columns. + + - `@objectstack/types`: new `collectConfiguredLocales(i18n)` and + `stampSearchPinyinEnabled(i18n)` — the single resolve-and-stamp helper for + `OS_SEARCH_PINYIN_ENABLED`. An explicit env value still wins; only a + positive locale-derived decision is stamped. + - `@objectstack/runtime`: `createStandaloneStack` stamps the decision from + the artifact's `i18n` before any plugin constructs a `SchemaRegistry`, and + surfaces `i18n` on its result like `requires`/`objects`/`manifest`. + - `@objectstack/cli`: the `serve`/`dev` boot now stamps through the same + shared helper (behaviour unchanged), so create/serve and plan/apply cannot + compute different schema views of the same source tree. + + A fresh CLI-created database is now also born with the same `__search` + columns the dev runtime would provision, instead of acquiring them on the + next dev boot. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- 2cb6d3c: fix(spec,runtime): `resolveService` returns the slot's contract too, and the `: any` escapes on core slots are gone (#4127) + + Batch 2 of the #4127 gate. #4168 typed `getService` — easy, because every one of + its call sites already passed a `CoreServiceName`. `resolveService` is the mixed + one, and it is where the remaining `any` lived. + + **Overloads split it exactly where the evidence does.** A `CoreServiceName` + resolves to the slot's contract; anything else keeps `any`: + + - **Core slots, however written.** 17 call sites address a core slot with a bare + literal — `'metadata'` ×10, `'automation'` ×3, `'auth'` ×3, `'ai'` — rather + than `CoreServiceName.enum.*`. The same slot was being addressed two ways; + both resolve to the contract now, with no edit to the call sites. + - **Everything else** — `protocol` (×22), `objectql` (×9), `mcp`, + `kernel-resolver`, `security`, `scope-manager`. Real services with no + `CoreServiceName` entry and no written contract. They keep `any` rather than + being given a shape here that nothing verifies: **that `any` is where the + ledger honestly ends**, and writing those contracts is its own change. + + **The typing was being erased at three call sites, and that is the actual + finding.** A `const x: any = await deps.resolveService('auth', …)` defeats every + bit of this — the annotation wins, and #4168's work does nothing there. Sweeping + for the pattern found three on core slots: + + **`/mcp` ×2 — two more undeclared methods.** The domain calls + `authService?.getMcpResourceUrl?.()` and `?.getMcpResourceMetadataUrl?.()`. + `AuthManager` implements both (and plugin-auth uses them internally); + `IAuthService` declared neither. Classic #4127 shape — call site and + implementation agree, the contract is the thing nobody wrote. + + The `: any` + optional-chaining combination made this _worse_ than the earlier + gaps, not better: it made the call invisible to the type system **and** + accidentally safe. An absent method returns `undefined`, so the skill route + silently fell back to deriving an MCP URL from the request host — meaning a real + disagreement between the auth service's canonical value and the derived one + would have looked exactly like normal operation. The whole point of + `getMcpResourceUrl` is that it comes off the auth `basePath` so the two _cannot_ + disagree about the API prefix; the route's own comment says "the auth service + owns the canonical value". + + Both are declared optional: an auth provider without MCP/OAuth support fills the + slot legitimately, and `getMcpResourceMetadataUrl` returning `null` (OAuth track + off — AS disabled or the origin fails the OAuth 2.1 transport rule) stays + distinct from the method being absent. + + **`/packages` ×1 —** `const metadata: any = await deps.getService(…metadata)`, + feeding `new SeedLoaderService(ql, metadata, …)`. Annotation dropped; it + typechecks against `IMetadataService` now. Its neighbours `protocol` and `ql` + keep their `any` for the honest reason above. + + No other core-slot lookup is annotated away — the sweep is exhaustive over + `domains/*.ts`. + + Verified: `@objectstack/runtime` **937 tests / 65 files**, `@objectstack/spec` + **7112 / 273** (3 new on the auth contract), adapter-hono **73**; `tsc --noEmit` + on spec, runtime, downstream-contract and all four examples; `pnpm lint`; all + nine `check:*` gates. `api-surface.json` is unchanged — the two additions are + interface MEMBERS, not new exports. + +- a3cb9c8: Retire the dev-mode `analytics` stub, and make the dispatcher gate `/analytics` on `handlerReady` rather than on service presence (#4000). + + Retiring the degraded analytics shim (#3891) made an empty `analytics` slot the honest signal: `/api/v1/analytics/*` 404s and discovery reports `unavailable`. `plugin-dev` refilled that slot with a stub, which re-created the retired shape in dev mode — the dispatcher gated on "is a service registered", so the stub was called like a real engine and its empty result came back as a 200. + + - `plugin-dev` no longer registers an `analytics` dev stub; the slot stays empty (`NO_DEV_STUB_SERVICES`). Every other dev stub is unchanged. + - The `/analytics` domain, its route-mount gate, and discovery's `routes`/`features` now share one predicate (`isAnalyticsServiceServeable`): a service that self-declares `handlerReady: false` (ADR-0076 D12 — `__serviceInfo`, or plugin-dev's legacy `_dev: true`) is treated as an empty slot. A `degraded` implementation that genuinely serves requests keeps serving; `discovery.services.analytics` still reports a registered stub as `status: 'stub'`, which says more than `unavailable` would. + + FROM → TO for dev setups that relied on the stub answering `POST /api/v1/analytics/query` with `{ rows: [], fields: [] }`: install the real engine — `@objectstack/service-analytics` runs an InMemory strategy and needs no database of its own. Nothing else changes; hosts that already install it (including `os serve`, where `analytics` is in `ALWAYS_ON_CAPABILITIES`) are unaffected. + +- a2266a6: fix(spec,data): the five RPC query aliases resolve by ONE fold — spec table, not per-reader prose (#3795) + + `RpcQueryOptionsSchema` accepts five legacy aliases next to their canonical + QueryAST keys and stated the precedence in prose only ("the normalizer uses + the new key"). With no fold in the schema, every reader re-implemented it — + the #3713 condition — and the two readers disagreed: + + | pair | spec prose | runtime dispatcher | metadata-protocol | + | --------------------- | ---------- | ------------------ | ----------------------------- | + | `where` > `filter` | canonical | canonical | **alias consulted first** | + | `fields` > `select` | canonical | canonical | **alias clobbered canonical** | + | `offset` > `skip` | canonical | canonical | **alias clobbered canonical** | + | `expand` > `populate` | canonical | — | **alias consulted first** | + | `orderBy` > `sort` | canonical | canonical | canonical | + + Four of five inverted in `protocol.ts`, so `?select=a&fields=b` answered + `[a]` on one path and `[b]` on the other — reachable from a plain HTTP + request. + + **The mapping now lives once, in the spec** (`RPC_QUERY_ALIAS_SLOTS` + + `foldQueryAliasSlots`, both exported), under the rule #4181 already + established for the filter pair: + + - an **alias alone** folds into its canonical key — `filter`→`where`, + `select`→`fields`, `sort`→`orderBy`, `skip`→`offset`, `populate`→`expand` — + and the alias key is **dropped from the parsed output**; + - **both spellings, same value**: redundant, tolerated, alias dropped; + - **both spellings, different values**: irreconcilable — picking a winner IS + the silent drop — so the parse fails (schema) / the request is `400 +INVALID_REQUEST` (wire), naming the spellings and the canonical key; + - an explicit **`null` spelling is a withdrawal**, never a conflict: a null + alias is dropped silently, a null canonical keeps its slot-specific answer. + + `RpcQueryOptionsSchema` and the four `filter`-mixin option schemas + (update/delete/count/aggregate requests) apply the fold as a parse transform, + so parsed output speaks canonical keys only — a TS consumer reading + `parsed.query.populate` now **fails to compile** instead of silently reading + `undefined` (the #3742 / #3764 shape, one layer down; hence the minor). The + protocol normalizer folds raw wire input by the same table (extended with the + wire-only `filters` / `$filter` / `$expand` spellings), and the runtime + dispatcher's second copy of the fold is deleted outright. + + **Authoring/callers unchanged for the supported cases**: every alias alone + keeps working on every path, and identical duplicates still pass. What + changes is mixed vocabularies with **different** values — previously answered + differently per route, now refused loudly on all of them — and a direct + `expand: [names]` array on `POST /data/:object/query`, which used to be read + by its indices ("Unknown field '0'") and now lowers to the expand record like + `populate` always did. + +- 5c13368: feat(objectql,runtime): the default-runner setters are first-wins, and the private-field probes that used to enforce that are gone (#4251) + + `setDefaultBodyRunner` / `setDefaultActionRunner` now enforce their own + documented contract — "the runtime layer sets this once per engine" — by + keeping the first runner and returning `false` for any later call. Public + accessors `getDefaultBodyRunner()` / `getDefaultActionRunner()` join them, and + the fields become real `private` members instead of `(this as any)` attachments. + + Before this, the invariant lived in the CALLERS: AppPlugin probed the engine's + private `_defaultBodyRunner` / `_defaultActionRunner` fields through `any` to + avoid clobbering another AppPlugin's runner on a shared kernel — an invariant + owned by every caller and enforced by none, and a private reach that a field + rename would have broken silently (the guard reads `undefined`, every AppPlugin + reinstalls). The engine's own `bindHooks` fallback and ObjectQLPlugin's + authored-action re-sync read the same fields the same way. All three read the + public accessors now; the only remaining `_default*` mentions in the repo are + comments and test doubles. + + Caller audit before the semantics change: every setter call site either owns a + fresh engine (the sandbox and hook-binder tests) or wants exactly + keep-the-first (AppPlugin) — nobody replaces a runner on a live engine. Return + type `void` → `boolean` is additive; AppPlugin uses it to keep its "Installed + default … runner" log truthful (skipped when the engine kept an earlier one). + + Pinned in hook-binder tests: second install refused end-to-end (the first + runner is the one that executes) and the accessors expose exactly what was + kept. + +- 1d5dc46: fix(runtime): carry `code` / `fields[]` across the sandbox boundary so form actions can anchor validation errors (#3918 follow-up) + + Found by dogfooding the merged #3918 chain against a running app. Submitting a + record that fails validation through a form **action** came back as: + + ``` + HTTP 200 + { "success": true, "data": { "success": false, + "error": "ValidationError: issued_on is required" } } + ``` + + No status a client could branch on, no code, no `fields[]`. The chain's + dispatcher fixes could not help: the field list was already gone before any + dispatcher exit ran. It was lost at the QuickJS boundary, twice — + + 1. **host → VM.** `vm.newError({ name, message })` dropped every other property, + so a body reaching a record `ValidationError` through + `ctx.api.object(x).update(...)` saw bare prose. + 2. **VM → host.** The wrapper's reject handler flattened the error to the string + `: ` before the host ever saw it. + + Both hops now carry an explicit **allowlist** — `code` and `fields` — alongside + the message, and `SandboxError` exposes them as `.code` / `.fields`. The + allowlist is a security boundary, not a style choice: host errors routinely hang + driver state, connection details or whole record payloads off themselves, and + anything crossing INTO the VM is readable by untrusted sandboxed code. Copying + the error's own enumerable keys would leak all of it. + + `/actions` then surfaces them, so a form can highlight the offending input: + + ``` + HTTP 200 + { "success": true, "data": { "success": false, + "error": "ValidationError: issued_on is required", + "code": "VALIDATION_FAILED", + "fields": [ { "field": "issued_on", "code": "required", … } ] } } + ``` + + **The `/actions` wire contract is deliberately unchanged.** The status stays + 200 and `success: false` remains the failure signal: that route has always + reported business failure in the payload (an action that "fails" is a normal + outcome, not a transport error) and every caller branches on `data.success`. + Making it a 4xx would be a break in exchange for a strictly additive fix, so the + fix is additive — `code` and `fields` are simply omitted when absent, and a + caller that ignores them sees exactly what it saw before. + + Message channels are byte-identical: `SandboxError.message` keeps the + ` '' threw:` debug wrapper for server logs and `.innerMessage` stays + the plain business text a toast shows. The structured payload rides alongside + them, never instead of them. + + Also adds `dispatcher-validation-error.real.test.ts`, which pins both dispatcher + exits against the **real** objectql `ValidationError` rather than a hand-built + fixture — including its deliberate absence of `.status`, the assumption the + whole #3918 fix rests on. The existing fixture-based tests restate that contract; + these check it, so a future change to the class fails a test instead of quietly + regressing production. + +- 627b188: fix(seed-loader): count reference fields dropped from rows that were still written + + The loader had two failure outcomes and only counted one. A record it cannot + write is counted in `errored`. But an unusable **reference value** (an object + where a natural key belongs, an array on a single-value field) is removed from + the record — never written as NULL, which would sever an existing link on + upsert replay — and the row is written **without it**. Nothing counted that. + + So a load that quietly severed N associations reported `totalErrored: 0`, and + every count-driven surface read clean. The CLI boot banner — the one seed signal + that survives `os dev`'s boot-quiet window and the default `warn` level — printed + `showcase 42 rows`, and the warn line said `0 dropped record(s)`: true, and + useless ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + + `SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` + now count it. It is deliberately **not** folded into `errored` — the row _was_ + written, so that would break the `inserted + updated + skipped` reconciliation + against `total`. The banner names it separately: + + ``` + ⚠ Seeds: showcase 42 ok / 3 lost links ⚠ + ``` + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected. + +- 857a6cf: fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085) + + The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is + a development platform, so it has to start without one — but `os serve +objectstack.config.ts` died during boot whenever the artifact was absent: + + ``` + Loading objectstack.config.ts... + [StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT… + + ✗ Service 'manifest' is async - use await + ``` + + Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with + only its `dist/objectstack.json` moved aside), and on every freshly authored + project between `os init` and its first `os compile`. The message named neither + the missing artifact nor a fix, so it read as an internal kernel fault. + + Three separate faults, each of which alone was enough to refuse the boot: + + - **`serve` registered the config-derived `AppPlugin` before the stack's own + `plugins[]`.** Registration order _is_ the kernel's init/start order, and that + slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and + `DefaultDatasourcePlugin` (which connects the database the app seeds through). + The wrap is now **appended** to `plugins[]`, the same slot + `createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot + and artifact-boot share one plugin order. The artifact path never hit this, + which is exactly what made a plugin-**order** bug look artifact-related. + + - **`ctx.getService()` reported a never-registered service as "is async".** + `PluginLoader.getService` is an `async` method, so its return value is _always_ + a Promise and its internal "not found" rejection can never surface + synchronously — the kernel read the answer off that Promise and told every + caller to `await` a service that did not exist, while the `not found` branch + below it was unreachable. It now decides from the registry: absent ⇒ + `[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged + `Service 'x' is async - use await`. The same crash now reads + `[Kernel] Service 'manifest' not found`, which points at the layer that is + actually wrong. + + - **`MetadataPlugin` treated an absent `local-file` artifact as fatal.** + `createStandaloneStack` always points it at `dist/objectstack.json`, so a stack + with no app at all could not boot. A **missing** local artifact is now "nothing + compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so + a later `os compile` hydrates the running server. The tolerance is + ENOENT-only — a malformed or unreadable artifact stays fatal — and + `bootstrap: 'artifact-only'` (sealed runtime, where the artifact _is_ the + deployment) keeps failing loudly rather than silently serving an empty runtime. + + `[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted + at callers for whom "no artifact" is a healthy state; a present-but-unusable + artifact keeps the loud warning. + + Pinned by an e2e pair that drives the real `os serve` with **no `os compile` + anywhere**: an app defined only by `objectstack.config.ts` (asserting its object + is in the started plugin set, not merely that boot survived) and a bare + `export default {}` platform. The #4012 fixture drops the `os compile` this bug + had forced on it. + +- 1e38158: fix(cli,runtime): an artifact you NAMED and a boot input you don't have are different failures — say which (#4110 follow-up, #4131 step 1) + + Three corrections, all from the same principle: a platform may boot with no + application (#4085), and that says nothing about how a MISSING NAMED INPUT + should be read. + + - **A named-but-missing artifact boots empty and silently.** #4110 made an + absent artifact non-fatal all the way down — right for the conventional + `/dist/objectstack.json`, which is just "not compiled yet". But + `OS_ARTIFACT_PATH` / `{ artifactPath }` skip the existence check by design, so + the tolerance reached them too: `OS_ARTIFACT_PATH=/nope os serve` printed + "booting from artifact", reached `Server is ready`, and named the missing path + NOWHERE in its output (serve's boot-quiet window drops the loader's calm + line). `createDefaultHostConfig` — the boot with no config, where the artifact + IS the deployment — now rejects a named local artifact that does not exist, + naming both the path and which source named it. The loader keeps its + tolerance, so the config-boot path #4085 fixed is untouched. + + - **"Configuration file not found" never said where it looked.** The two things + that actually happen are a typo'd filename and the wrong working directory, + and the second is the common one. It now names the config path, the artifact + path, and that `OS_ARTIFACT_PATH` is unset — and still refuses rather than + inventing a zero-object platform, pointing at `objectstack start` for a boot + that is app-less on purpose. + + - **That refusal was being truncated.** `this.exit(1)` unwinds to oclif's + `process.exit`, which does not drain a piped stdout, so a diagnostic split + across several `console.log` calls loses its tail — measured: only the first + two lines of the new message survived a pipe, i.e. exactly the part that says + where to look went missing. Both of `serve`'s pre-flight refusals now emit one + write. Caught by the e2e added here, not by review. + + Also corrects the plugin-ordering claims in `createStandaloneStack` and in the + test that pinned them: the comment said the datasource plugin's array position + "MUST precede ObjectQLPlugin: its start() connects the default driver", and the + test asserted that index with the same rationale. The connect happens in + `init()`, and the kernel resolves order from the dependency graph — which hoists + ObjectQLPlugin ahead of the datasource plugin (measured: 6 slots earlier), the + reverse of what the slot reads as. The test now pins the declared dependency + that actually orders the two inits, which deleting the array position cannot + break and deleting the declaration does. #4131 tracks making the AppPlugin end + of that contract enforced rather than conventional. + +- 65a3a84: fix(runtime,spec): guard the service-lookup typing with a lint rule — which immediately found the project-membership gate not gating (#4127) + + Batch 4 of the #4127 gate. #4168/#4176/#4202 made a slot lookup return the + slot's contract. Nothing protected that: an `any` annotation on the **result** + switches the checking back off for that call site, silently, with no test + failing and no visual difference from code that has it. Three such sites already + existed and were found by grep — the same unrepeatable sweep this work replaced. + + **The rule** bans `: any` / `as any` on a `resolveService` / `getService` / + `getRequestKernelService` result. Slots with no written contract (`protocol`, + `mcp`, `kernel-resolver`, `scope-manager`) are exempted **by name, centrally**, + in `eslint.config.mjs` — not by inline disables, because `pnpm lint` runs + `--no-inline-config` and ignores those on purpose. The effect is the one worth + having: a deliberate gap is a reviewed line in one file, a careless one is a + build failure, and they stop looking identical in the code. + + **Its first run found a live fail-open.** `enforceProjectMembership` read the + session as `authService?.api?.getSession?.(…)` with no `getApi()` fallback — the + only one of the codebase's three `.api` readers without it. `plugin-auth` + registers `AuthManager`, which has **no `.api` member at all**. So the read + yielded `undefined`, `userId` stayed unset, and the function returned at its + "anonymous — upstream auth will decide" line **before ever querying + `sys_environment_member`**. A signed-in non-member passed the gate, on every + deployment with project scoping on — which is where the flag defaults to true. + Anonymous callers were still denied elsewhere (#2567/#3963), so this was + specifically the signed-in-non-member case. + + The existing test for that gate mocked auth as `{ api: { getSession } }` — the + legacy shape the shipped provider does not have — so it was green throughout. + That is the **fourth** test in this work line found encoding a contract nobody + implements, after batch 1's three `auth.handler` mocks and batch 3's + `status: 'open'`. The new test uses the `getApi()` shape and fails against the + pre-fix code. + + **Also found by the rule**, all the same #4127 shape (implemented, called, + undeclared) and all now declared: `IAuthService` gains `api`, `getApi`, + `isAuthGateActive` and `verifyMcpAccessToken`; `IMetadataService` gains `load` + and `loadDiagnosed`. `getApi`'s return type is the **evidenced subset** — + `getSession({headers})` and the three fields callers read — not a re-declaration + of better-auth's handle, which belongs to that library. + + **And the pattern's real root:** the lookup facade returning `any` was + re-declared in **three** places. Batches 1-3 typed `DomainHandlerDeps` and left + `ActionExecutionDeps` and `resolve-execution-context`'s `ResolveOptions` still + saying `any` — so the copy that stayed untyped was the way around all the + others, and it is where the auth reads lived. All three are typed now. + + Completing the interface: `getRequestKernelService` gets the same overload split + (its one caller resolves the same `objectql` slot the `resolveService` fallback + beside it does, so the two arms of one expression had different types), and + share-links' `getEngine` loses a `Promise` return annotation — a **third** + erasure syntax after `: any` and `as any`, and one this AST rule cannot see. + That residual is documented in the config. + + `getObjectQL` **stays** `any`, deliberately, with the reason recorded: it exists + to reach ObjectQL's surface beyond `IDataEngine` (`registry`, `executeAction`), + which has no contract. Typing it `IDataEngine` would be the comfortable-looking + lie. + + Verified: `@objectstack/runtime` **952 tests / 67 files**, `@objectstack/spec` + **7147 / 275**, plugin-auth **579**, rest **512**; `tsc --noEmit` on spec, + runtime, downstream-contract and all four examples; `pnpm lint` (with + `--no-inline-config`); all nine `check:*` gates. + +- de6daa5: fix(runtime)!: the /share-links dispatcher domain stops emitting a duplicate `link`/`links` beside `data` (#4038) + + The producer-side other half of #3983. That PR moved the sharing plugin's routes + onto the declared envelope; this removes the compatibility shim the dispatcher + twin had been carrying _because_ that surface answered bare. + + Create and list answered with the payload under **two** keys: + + ```ts + { success: true, data: link, link } // POST /share-links + { success: true, data: links, links } // GET /share-links + ``` + + The duplicate existed so readers predating the envelope kept working — which is + why objectui's `ShareDialog` reads `body.links ?? body.data`. Once #3983 made both + surfaces answer `data`, that first branch had no producer left, and the duplicate + had no reader in **any** repo: + + - **framework** — no consumer of these routes at all + - **objectui** — `ShareDialog` already falls through to `body.data` + - **cloud** — swept: it only _registers_ `SharingServicePlugin` into per-environment + kernels with `registerShareLinkRoutes: false` so this dispatcher serves the paths. + It never calls them and never reads a body. That sweep is what #4038 was waiting + on, and it came back clean. + + ## Shape + + | route | was | now | + | ------------------- | --------------------------------- | -------------------------- | + | `POST /share-links` | `{ success, data: link, link }` | `{ success, data: link }` | + | `GET /share-links` | `{ success, data: links, links }` | `{ success, data: links }` | + + `data` is unchanged in both — only the duplicate key is gone. Anything reading + `body.data`, or going through `ObjectStackClient.unwrapResponse`, sees no + difference. A raw reader of the top-level `body.link` / `body.links` must move to + `body.data`. + + The list route now routes through `deps.success(...)` like the domain's other + three. Create stays hand-built, because `deps.success` hardcodes status 200 and + this route is a **201** — the same reason `/keys` hand-builds its own 201, and the + same shape it uses. + + ## Guard + + `scripts/check-route-envelope.mjs` does not and cannot cover this file: it scans + route modules that write via `res.json(...)`, while dispatcher domains return + `{ status, body }` for a central sender. So the drift was invisible to it by + construction. Three tests in `domain-handler-registry.test.ts` cover it instead — + two per-route, plus a general one asserting no success body carries a top-level + key outside `success` / `data` / `meta`. Restoring the duplicates fails all three. + +- bca935b: fix(spec,runtime): the slot→contract ledger extends past `CoreServiceName`, and `/security` stops passing unvalidated input to the security service (#4127) + + Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`). + + Three slots — `security`, `shareLinks`, `objectql` — each had a written + contract, a provider registering them, and call sites already inside the + contract. The only missing link was that the slot name was not a + `CoreServiceName` member, so nothing could connect them and all three sat behind + `as any`. + + **The ledger extends past the enum rather than the enum growing.** The two + answer different questions, and conflating them is what left these untyped: + `CoreServiceName` answers _"what happens at boot when this slot is empty?"_ — it + sits beside `ServiceCriticality` and drives startup orchestration and discovery, + so adding a member changes runtime behaviour and is effectively permanent. The + ledger answers _"what shape occupies this slot?"_ — pure type information. These + three need only the second, so `ServiceSlotContracts extends CoreServiceContracts` + adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero + runtime effect. If one is later promoted to a genuine core service, its entry + moves up and nothing else changes. + + Evidence, as always, before an entry: `plugin-security` registers `security` and + `ISecurityService`'s own doc names that registration; `plugin-sharing` registers + `ShareLinkService`, which declares `implements IShareLinkService`; and `objectql` + is an **alias of `data`** — `packages/objectql`'s plugin registers the _same + instance_ under both names two lines apart, so one object was resolving as + `IDataEngine` through one name and `any` through the other. `protocol` (22 call + sites) and `mcp` have no written contract and stay unmapped. + + **Turning it on found four things, all on the `/security` admin surface:** + + 1. **Request input reached the security service unvalidated.** `?status=` was + `String(query.status)` — any string — handed to a method whose contract + declares exactly three values, and from there into the query's `where` + clause. Not an injection (the `where` is structured, never interpolated), but + `?status=garbage` matched no row and returned an empty list, which reads as + "there are no suggestions" rather than "that is not a status". Now a 400. + + 2. **A test pinned that bug as expected behaviour.** The existing case asserted + `status: 'open'` — not one of the three declared values — reached the service + and returned 200. It proved the delegate carried _a filter_ and nothing about + that filter being a status. Same shape as batch 1's `auth.handler` mocks: + coverage in appearance, a wrong contract in substance. + + 3. **and 4. Two writes could not prove they had a caller.** + `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare + `callerContext: SecurityContext` non-optionally — deliberately, since the + read beside them declares it optional — and the domain passed a possibly- + `undefined` execution context. + + **This was not a live hole**, and the distinction matters: with no execution + context `shouldDenyAnonymous` already denied, because it sees no + `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam + never passes, so it fell through to `return true`. What it never did was + narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is + behaviour-preserving and makes the invariant legible to the compiler and the + next reader. + + The `?status=` rejection is the one **behaviour change**: an unknown status was + a silent empty list and is now a 400 naming the accepted values. The accepted + set is a `Record` keyed on the contract type, so adding a status to the contract + leaves a key missing and renaming one leaves a key excess — either fails to + compile, where a plain array would have drifted silently. + + Verified: `@objectstack/runtime` **945 tests / 66 files** (+8), `@objectstack/spec` + **7141 / 274** (+29), plugin-security **677**, plugin-sharing **225**; + `tsc --noEmit` on spec, runtime, downstream-contract and all four examples; + `pnpm lint`; all nine `check:*` gates. + +- d92c72d: fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + + The three selectors from #4321 all key off the erasure and the lookup being in + ONE expression. Split them and every selector misses: + + ```ts + let ql: any; + try { + ql = ctx.getService("objectql"); + } catch { + /* optional */ + } + ``` + + Selector 1 needs the call inside the declarator (this declarator has no init), + selector 2 needs `as`, selector 3 needs a type argument. The contract is erased + exactly as in `const ql: any = ctx.getService(…)`. + + **Why this could not wait for the batches.** The baseline's monotonicity check + means a file that leaves the grandfather list can never be re-added. So every + batch converted more of this shape from "grandfathered" into "lint covers this + file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` + into that state. A ratchet that reports a cleaner number the more you sweep is + the #4342 failure wearing different clothes, and the fix only gets more + expensive per batch shipped. + + **It is a rule, not a fourth selector, and that is the whole finding.** esquery + can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell + which declaration the assigned identifier resolves to — so it would equally + flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in + runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the + identifier needs SCOPE analysis. That is cheap and needs no type information, so + this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it + is a rule, and the earlier "just one more selector" estimate was wrong. + + Verified against exactly that: the rule flags all 16 real sites and none of the + 8 correctly-typed lookalikes. + + **Scale.** The baseline goes 140 → **169 sites** with the file count unchanged + at 37: 29 sites were already inside grandfathered files and simply invisible. + 16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 + in files never listed, 1 the regex sweep had missed) and are typed here — + `runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, + `plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, + `runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was + added; the key set still only shrinks. + + Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, + `II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they + do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, + `EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of + those record something worth naming: `IHttpServer` has no `getRawApp()` (the + contract is framework-agnostic and the raw app is Hono's own handle), and + ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader + at all — the engine attaches them via `(this as any)` and publishes nothing, + while `getHookMetricsRecorder()` exists for exactly that question about the + metrics recorder. Declared rather than laundered through `any`, and filed. + +- 8dcc0f5: fix(spec,runtime): the service-lookup `any` guard now sees the type-argument form, and its scope stops at nothing under `packages/` (#4251) + + The #4127/#4214 rule banned `: any` and `as any` on a service-lookup result but + not `getService('data')` — the form the codebase actually used (80 sites, + zero matches), erasing the slot contract identically. And the rule's `files` + covered only `packages/runtime`, leaving the composition roots (rest, + plugins/_, services/_) that hold most lookups unlinted. Both gaps closed: a + third AST selector catches the type-argument form, the scope is now all of + `packages/`, and the 40 not-yet-swept files are grandfathered in a visible, + shrinking ratchet list (`SLOT_LOOKUP_UNSWEPT`) — enumerated at 180 sites by + running the widened rule with the list emptied. `http.server` joins + `UNCONTRACTED_SLOTS` (three providers, no written contract). + + Typing the three in-scope runtime sites surfaced its first yield: both + `addDatasource` datasource-registration branches (DefaultDatasourcePlugin, + DriverPlugin) probed a method **no metadata service implements**, so they had + never run on any boot — deleted rather than typed against a phantom shape. The + inert `DriverPluginOptions` they configured are tracked in #4320. + `registerInMemory('datasource', …)` is the actual visibility path (#3827). + + Contract members declared from evidence, both optional: `IDataEngine` gains + `getDefaultDriverName?()` / `getDriverByName?()` (ObjectQL's driver registry — + the surface `os migrate` and serve's storage detection reach through + `driver.` services), `IMetadataService` gains `registerInMemory?()` + (MetadataManager's boot-time seeding primitive). Callers that supplied `` + to these lookups should pass the slot's contract type instead — or nothing: + an unmapped slot deliberately resolves to `unknown`, not `any`. + +- 75b9e51: fix(spec,runtime): a service-slot lookup returns the slot's contract, not `any` — and it immediately found two more gaps (#4127) + + #4127's most valuable item was the one it did not do: "**给这个类别加个 gate**". + The four contract gaps it catalogued were found by a human sweeping the + dispatcher by hand. A sweep is not repeatable, and this one was not complete — + see `/auth` below. + + The root was one line: + + ```ts + // domain-handler-registry.ts + getService(name: string): any; // ← every domain's service handle + ``` + + Against `any`, a domain calling a method its contract declares and a domain + calling a method nobody declares typecheck identically. That is what let #4087 + ship a `/storage` handler passing two arguments no implementation takes, and + what hid #4127's four. + + **`CoreServiceContracts` — the slot → contract ledger.** `CoreServiceName` named + the slots and `contracts/*` described them; nothing connected the two. It does + now, and `getService(name: K)` resolves through it, so a call outside the + contract is a **compile error at the call site**. + + An entry is a claim, so entries are only made where the binding is evidenced — + by the provider that registers the slot (`service-storage` → `file-storage`, + `objectql` → `data`, whose own comment reads "ObjectQL implements IDataEngine"), + or by dispatcher work that proved it (#4143/#4150 for `automation`, + `notification`, `i18n`). **`ui` is deliberately unmapped**: the slot exists and + `domains/ui.ts` serves it, but no `IUiService` was ever written. An unmapped slot + resolves to `unknown`, not `any` — it must be cast deliberately, so the gap stays + legible instead of looking checked. + + **Two findings, within minutes of turning it on:** + + **`/auth` called a method that does not exist.** `domains/auth.ts` probed + `authService.handler(request, response)`. `IAuthService` declares + `handleRequest(request): Promise`; `AuthManager` implements exactly + that and has no `handler`. The probe was false on every deployment — #4143's dead + `automation.trigger` again. **#4127's manual sweep never mentions `/auth`**, + neither in its gap list nor in its "扫干净的" list: the file the compiler flagged + first is the one the human pass skipped entirely. + + Not a live hole: the Hono adapter calls `handleRequest` itself and only falls + through to the dispatcher when no usable auth service answered, so nothing was + served by the mock in that deployment. But reading the contract makes the branch + reachable for the first time — a host calling `handleAuth` directly WITH an auth + service registered used to get `mockAuthFallback`'s `mock_` session instead + of real authentication, and now gets the auth service. + + **`POST /analytics/sql` invoked an optional method unguarded.** `generateSql?` is + optional on `IAnalyticsService` — unlike `query`/`getMeta` beside it — and the + call had no probe, so a provider without it answers a 500 from `TypeError` + instead of saying the capability is absent. service-analytics implements it, + which is why nothing noticed; the contract permits a provider that does not, and + this slot is multi-provider by design. It answers `handled: false` now, the same + 404 the file's entry gate already gives for absent analytics capability. + + **`isServiceServeable` is a type guard now** (`svc is NonNullable`). Every + domain already calls it first on a resolved slot, so one predicate narrows away + the `undefined` for the whole handler body — the null check and the capability + check were always the same check. + + **The test-side hole, closed for this batch.** #4127's last section predicted it: + the mocks are written to what the handler wants, so handler and test agree with + each other and with no implementation. **Three** tests across two files mocked + `{ handler }` for auth — including one whose entire subject was the _resolution + path_, so it proved the lookup worked and nothing about the call. `ContractMock` + (`Partial>`) now guards the mocks: keys are checked + against the contract, signatures deliberately left `unknown` so `vi.fn()` does not + force everything back to `as any`. The automation mock's `trigger` — genuinely + not on the contract — stays as an explicit, labelled negative control outside the + checked literal, because a test asserting the route _never_ calls it is the point. + + Nothing is renamed and no runtime behavior changes except the two fixes above. + The 12 domains not calling `getService` are untouched; `resolveService` (which + also takes non-`CoreServiceName` names like `protocol` and `objectql`) is + deliberately left for a later batch rather than widened here. + + Verified: `@objectstack/runtime` **933 tests / 65 files**, `@objectstack/spec` + **7095 / 272** (6 new, pinning the map against the enum in both directions), + service-automation **457**, service-analytics **413**, service-messaging **137**, + service-i18n **62**, adapter-hono **73**; `tsc --noEmit` on spec, runtime, + downstream-contract and all four examples; `pnpm lint`; and all nine + `@objectstack/spec` `check:*` gates — clean. + +- 77a77fd: test(runtime): correct the #4073 evidence — the `registerStandardEndpoints` flip IS a no-op for a composed host + + #4192 added a test concluding that turning the flag off makes `/api/v1/data/:object` + a 404, and blocked the #4073 retirement on it. That conclusion was wrong. + + It mounted `createRestApiPlugin({})` against a STUB `objectql` service. REST + generates CRUD from the object registry, so it needs a real engine — driver plus + registered objects — and its own `api.api` config. Under-provisioned it serves + nothing, which says nothing about REST. + + Provisioned the way `client.environment-scoping.test.ts` does it (that suite runs + `registerStandardEndpoints: false` and asserts `GET /api/v1/data/task` → 200 from + REST), `/data/:object`, `/discovery` and `/.well-known/objectstack` all return + byte-identical responses with the flag on and off. + + The test now asserts that parity directly rather than a status code, because a + status was what misled it: `/data/task` answers 404 `OBJECT_NOT_FOUND` here — the + engine's answer, i.e. a route that WORKS — where a routing miss would be + `{"error":"Not found"}`. A separate assertion pins that the compared routes are + live, so parity cannot be satisfied by two identical misses. + + No production code changes. The default is untouched: flipping it is still a real + change for a BARE host mounting neither REST nor the dispatcher, and that is an + API decision, not one this test makes. + +- d82f8c0: test(runtime): pin who actually serves `/data` and `/discovery`, blocking the #4073 default flip on evidence + + #4073 plans to retire `registerStandardEndpoints` by flipping its default to + `false`, on the premise that everything it mounts is duplicate supply. Booted for + real — real `HonoServerPlugin`, real dispatcher, real `createRestApiPlugin`, real + listener, in `serve.ts`'s registration order — that premise holds for only one + half of the surface: + + - **`/discovery` + `/.well-known/objectstack` — safe.** They cede by an explicit + `kernel.hasPlugin(rest|dispatcher)` check (#4018), so the dispatcher's computed + payload answers whether the flag is on or off. Order-independent. + - **`/data/:object` — not safe.** There is no cede, and the shadowing was + asserted purely on "REST registers first and wins". With the flag OFF the path + returns **404**, with REST mounted or not. The flag's raw surface is the only + thing answering it in every composition this harness can boot. + + So the flip is not the no-op the plan describes. This adds the harness that says + so, asserting the current matrix, so the next attempt has to confront it rather + than re-derive the assumption. No production code changes. + +- 2053714: fix(hono,plugin-hono-server,runtime): one CORS source and one registry key — the last derivable copies from the #3786 sweep + + Re-ran the sweep across all 72 packages. The earlier pass globbed `packages/*/src`, + which is one level deep, so it missed everything under `packages/plugins/` and + `packages/adapters/` — the "sweep is basically clean" report was based on an + incomplete scan. + + **A stale CORS default, on the one description callers actually read.** + `HonoCorsOptions.allowHeaders`' TSDoc promised + `['Content-Type', 'Authorization', 'X-Requested-With']` "which is sufficient for + cookie and bearer-token auth". The real default carries three more: + `X-Tenant-ID` and `X-Environment-Id` (multi-tenant routing) and `If-Match` (the + OCC token on record PATCHes, objectui#2572). Sizing a custom `allowHeaders` + against that sentence drops all three and every cross-origin save fails with + "Failed to fetch". + + The instructive part: **three** Hono CORS sites each carried their own copy of + the defaults under "keep in sync" comments, and the copies all agreed. What + drifted was the _doc_ — the only description with no counterpart to be diffed + against, and the only one a caller reads. + + Both defaults are now single constants, `DEFAULT_CORS_ALLOW_HEADERS` and + `DEFAULT_CORS_EXPOSE_HEADERS`, exported from `@objectstack/plugin-hono-server` + and imported by the adapter (which already depends on it — no new edge). The + TSDoc links them rather than restating, and documents an asymmetry it never + mentioned: `allowHeaders` REPLACES the default, `exposeHeaders` MERGES with it. + + `hono-plugin.test.ts` stopped stubbing `./adapter` wholesale and keeps the real + constants via `importOriginal` — it asserts exact header lists, so a mocked copy + would make the test agree with itself rather than with what ships. Verified: + removing `If-Match` from the constant fails `should allow If-Match by default`, + by name. + + **A third copy, in the public protocol docs.** `content/docs/protocol/kernel/ +http-protocol.mdx` advertised `Access-Control-Allow-Headers: Authorization, +Content-Type` — two of the six — and methods missing `PUT` and `HEAD`, with no + mention of the exposed headers at all. That is the copy an integrator builds a + client against: reading it, you would not know `If-Match` is permitted (so you + would not attempt OCC) or that `set-auth-token` is readable (so a rotated + session would look like a bug). Corrected, with the three non-obvious allowed + headers and the two exposed ones explained, and a pointer to the constants as + the source of truth. + + **A hand-copied service-registry key.** `runtime`'s share-links domain resolved + `'shareLinks'` as a string literal, copied from `SHARE_LINK_SERVICE` — whose own + doc-comment says "keep in sync with the SharingPlugin registration". It now + imports the constant. A drifted copy resolves nothing, so every share link + answers 501 "Sharing is not configured for this environment" on an environment + where it is configured perfectly well. + + **Plus a duplicate ledger entry**, which is the same defect one level up: + `check-generated.ts` carried two `NO_GENERATOR` entries for + `check:strictness-ledger`, because #4203 and #4252 each added one without seeing + the other. Functionally harmless (the ledger is read into a `Set`) but it leaves + two comments telling overlapping versions of the same story. #4203's is kept — + it is the more complete account and it is the PR that fixed the underlying + problem. + + Checked and deliberately left alone: `ApprovalStatus` (5 values) and + `ApprovalActionKind` (12 values) versus their `plugin-approvals` selects — diffed + verbatim, no drift today, still hand-copied across a package boundary. + +- 7309c81: test(runtime,client,metadata): back the remaining suites with in-memory SQLite instead of the mingo driver (#4065) + + Ten test files used `InMemoryDriver` as a convenience backing store — somewhere + for rows to go while the suite proved something else (REST routing, datasource + auto-connect, the batch `$ref` contract, metadata history). They now run on + `SqliteWasmDriver` at `:memory:`, the same engine `@objectstack/verify`'s + `bootStack` already gives the dogfood gate: pure JS (no native build, CI-safe on + any runner) and real SQL semantics. + + The point is fidelity, not tidiness. Production runs SQL, and mingo differs from + it in ways that let a suite pass while the behaviour it stands for is broken. + Every failure this migration produced was a fixture defect the memory driver had + been absorbing: + + - **Tables were never created.** `driver.create()` on the memory driver is a + bare `table.push()` onto an auto-vivified array, so an object registered + _after_ `kernel.bootstrap()` — which misses the boot-time schema sync — looked + fine. On SQL the first write fails with `no such table`, which the REST error + mapper turns into a **404 `OBJECT_NOT_FOUND`**: a routing-shaped symptom for a + DDL-shaped cause. Four suites needed an explicit `syncObjectSchema`. + - **A missing object declaration read as working.** `notifications.hono.integration` + writes `sys_notification`, which `MessagingServicePlugin` does not declare — + it is a platform object, and that lean kernel never booted `platform-objects`. + Auto-vivification hid the omission entirely. The suite now registers the real + `SysNotification` rather than a hand-copied stand-in, so there is still exactly + one schema for it (Prime Directive #12). + - **`connect()` was optional.** The memory driver needs none; a SQL driver does. + + What deliberately did NOT move: `read-coercion-conformance` keeps its two-driver + matrix (proving a stored value reads back as its declared type on _both_ engines + is the entire point of that gate), and the suites whose subject IS the memory + driver or its wiring — `standalone-stack` (`memory://` scheme), + `sqlite-driver-fallback` (the dev step-down), the CLI's driver-label tests, and + driver-memory's own suite. + + `datasource-autoconnect` is in that second group as of #4083, which landed a + regression test there for exactly the memory-pool property this PR originally + proposed to migrate away from. Moving that file to SQLite would have left the + new test passing vacuously — a wasm-SQLite pool never writes `.objectstack/` at + all — so it stays on the memory driver and keeps guarding what it was written + to guard. + + No new coverage is claimed here: each suite asserts exactly what it asserted + before, against a more faithful store. + +- 43fc039: Discovery's `/ui` advertisement reads what `/ui` reads: the `protocol` service, not the vestigial `ui` slot (#4093). + + `domains/ui.ts` serves `GET /ui/view/:object` off the `protocol` service and 503s without it; the `ui` core-service slot never enters that decision, and nothing in the platform registers a `ui` service — plugin-dev's shapeless placeholder was its only occupant ever, and ADR-0115 retired it. Gating `routes.ui` on slot presence was therefore wrong in both directions: a dev boot with the placeholder but no protocol advertised a route that could only 503, and every boot without a placeholder — production always, and all dev boots post-ADR-0115 — hid a route that serves fine. + + `routes.ui` and `services.ui` now gate on `typeof protocol?.getUiView === 'function'` — the domain handler's own guard, byte for byte, the same rule the `mcp` advertisement follows. `services.ui` reports the serving implementation (provider `metadata-protocol`, honoring any `__serviceInfo` it declares), and the unavailable message names the actual remedy — register MetadataPlugin (`@objectstack/metadata-protocol`) — instead of "install a ui plugin", which names a plugin that does not exist. + + FROM → TO: `routes.ui` / `services.ui` may newly appear in deployments where the protocol service is registered (the route always served there; discovery just never said so) and newly disappear in protocol-less boots (it never worked there). No handler behavior changes. + +- Updated dependencies [6a67d7a] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [3ec8186] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [bb1ce2e] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [ea24593] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [fccec22] +- Updated dependencies [2af1988] +- Updated dependencies [b3a2318] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [fae74b5] +- Updated dependencies [7bf5349] +- Updated dependencies [366105c] +- Updated dependencies [c9d254a] +- Updated dependencies [42e3b01] +- Updated dependencies [c8124e5] +- Updated dependencies [9e8f04d] +- Updated dependencies [39eb01b] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [9881074] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [f0d6594] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [6f98c2d] +- Updated dependencies [a4a9944] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [10575f3] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [974c6d4] +- Updated dependencies [7309c81] +- Updated dependencies [495019b] +- Updated dependencies [20bc1ec] +- Updated dependencies [ac6c0be] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [9e01213] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [3fe0ff1] +- Updated dependencies [be7945a] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [4475c59] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [f5fe061] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [8d5bb5a] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [a62bd9e] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [c53aa53] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [3245174] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/driver-memory@17.0.0-rc.1 + - @objectstack/driver-sql@17.0.0-rc.1 + - @objectstack/metadata@17.0.0-rc.1 + - @objectstack/plugin-security@17.0.0-rc.1 + - @objectstack/rest@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/plugin-auth@17.0.0-rc.1 + - @objectstack/metadata-protocol@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/service-cluster@17.0.0-rc.1 + - @objectstack/service-datasource@17.0.0-rc.1 + - @objectstack/service-i18n@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 42ab1076ac..e48a9a43ec 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 52a9f19282..bce8344d80 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,74 @@ # @objectstack/sdui-parser +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + ## 17.0.0-rc.0 ## 16.1.0 diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index bf74b7fda3..cd2fbee84f 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 e13a4d9e06..108edfecba 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,642 @@ # Changelog — @objectstack/service-analytics +## 17.0.0-rc.1 + +### Minor Changes + +- 99ffc04: fix(analytics)!: a measure emits what it declares, instead of `COUNT(*)` (#4157) + + `NativeSQLStrategy.resolveMeasureSql` answered `COUNT(*)` to three different + questions it could not otherwise answer — each time aliased under the name the + caller asked for, so the result looked like an answer: + + 1. **A measure the cube does not declare.** `lookupMember`'s synthetic + relation fallback is dimension-only, so any undeclared or mistyped measure + name landed here. `measures: ['revenue']` against a cube without it returned + `COUNT(*) AS "revenue"` — a row count presented as revenue. + 2. **A `number`/`string`/`boolean` metric.** `AggregationMetricType` documents + these as _"Custom SQL expression returning a number / string / boolean"_: the + measure's `sql` **is** the computation — a ratio, a `CASE`, a window + function. The expression was discarded and replaced by a row count. + 3. **An unrecognised `type`.** Same silent substitution. + + Now: an undeclared measure and an unrecognised type **throw**, naming the + declared measures and both accepted vocabularies respectively; a custom- + expression type emits its expression unwrapped. The six aggregates are + unchanged. + + **A dot no longer implies a relationship hop.** `qualifyAndRegisterJoin` split + any dotted string into a join chain, so the expression `SUM(account.amount)` + became `"SUM(account"."amount)"` _plus_ a `LEFT JOIN "SUM(account"` — invalid + SQL naming a table that does not exist. Harmless only while the result was + being thrown away for `COUNT(*)`; emitting the expression makes it matter. A + dotted string is now treated as a path only when every segment is a bare + identifier, so `account.amount` still lowers to a qualified column and a join, + and an expression is emitted as written. That also fixes the same mangling for + an _aggregate_ measure whose `sql` is an expression — `type: 'sum'` with + `sql: 'SUM(account.amount)'` was producing the same garbage. + + **Breaking, narrowly.** Two inputs that used to produce SQL now raise: a query + naming an undeclared measure, and a cube measure with a type outside + `AggregationMetricType`. Both were returning a wrong number rather than data, + so nothing correct can depend on them — but a caller that was silently getting + row counts will now see an error, which is the point. This is the trade #3948 + settled for the drivers. + + Datasets are unaffected: `aggregateToMetricType` only ever emits an + `AggregationFunction` member, so a compiled dataset never had a + custom-expression measure or an unknown type. The reachable path is a + hand-authored Cube. + + `metric-type-coverage.test.ts` asserts the aggregate and expression sets + _partition_ `AggregationMetricType`, so a tenth metric type fails a test rather + than reaching the throw. Both sets are named, not derived as each other's + complement — deriving would classify a new _aggregate_ as an expression and emit + a bare column, a different silent wrong answer. + + Verified: **460 tests across 35 files** green, including the four suites that + assert `COUNT(*)` — all of them use a _declared_ `type: 'count'` metric, so none + relied on a fallback. The 14 new tests were confirmed to fail against the old + behaviour (6 of 10 in the behaviour suite) before the fix. + +### Patch Changes + +- b4be309: fix(analytics): a new spec aggregate can no longer silently return a row count + + Track C item 4 of objectstack-ai/objectui#2945 — _"`AggregationFunction`: three + places in lockstep"_. They agreed only by coincidence, and the failure mode when + they stopped agreeing was silent wrong numbers. + + The three: + + 1. `AggregationFunction` (`@objectstack/spec/data`) — eight members, what an + author may declare as a dataset measure's `aggregate`. + 2. `UNSUPPORTED_AGGREGATES` (`dataset-compiler.ts`) — `array_agg`/`string_agg`, + rejected at compile time with a clear error. + 3. The aggregate `switch` in `native-sql-strategy.ts` — six cases, then + `default: return 'COUNT(*)'`. + + 8 − 2 = 6 = the six cases, today. Add a ninth member to the spec — `median`, + `percentile`, anything — and it would: + + - pass the compiler's gate, since it is not in `UNSUPPORTED_AGGREGATES`; + - be **advertised as supported** by that gate's error message, which listed + `count, sum, avg, min, max, count_distinct` as hand-written prose — a third + copy of the vocabulary; + - reach the strategy's `switch`, match no case, and fall to + `default: COUNT(*)`. + + The author asks for a median and gets a row count. No error, no log, wrong + figures on a dashboard — the same silent-wrong-answer shape as the filter + operators in #3948, in the analytics SQL builder. + + **The fix is derivation plus a guard, with no behaviour change.** The `switch` + becomes `AGGREGATE_SQL`, a table whose coverage is assertable; the error + message's prose list becomes `SUPPORTED_AGGREGATES`, derived as + `AggregationFunction.options` minus `UNSUPPORTED_AGGREGATES`; and + `aggregation-lockstep.test.ts` asserts the arithmetic — the lowered set equals + the admitted set, every spec member is either lowered or explicitly rejected, + nothing is both, and the rejection list names only aggregates the spec has. + + Verified by adding a hypothetical `median` to the spec, which now fails three + assertions naming it, including _"these would fall through to the COUNT(_) + fallback and return a row count"\*. Before this change the same edit was green. + + Nothing is narrowed and no SQL changes: the same six aggregates lower to the + same six expressions, and the `COUNT(*)` fallback still catches everything else. + + **Reported, not fixed:** that fallback is also reached by a measure whose `type` + is `number`/`string`/`boolean` — a custom SQL _expression_, per + `AggregationMetricType` — whose expression is then replaced by a row count. + Datasets cannot produce one (`aggregateToMetricType` only ever returns an + `AggregationFunction` member), so it is reachable only from a hand-authored + Cube. Emitting `col` instead is a behavioural change in an analytics SQL path + and deserves its own change with its own tests; the strategy's doc comment now + records it. + +- 7a55913: fix(service-analytics): a `$between` analytics filter no longer vanishes from the query (ADR-0053 D-A3.1) + + A dashboard widget or dataset whose filter used `$between` was querying **every + row**. `normalizeAnalyticsFilters` maps Mongo-style operators onto the internal + pipeline form, `$between` was missing from that map, and an unmapped operator is + skipped — so the predicate was silently dropped from the compiled WHERE clause. + Both strategies read that normalizer, so both the raw-SQL and the ObjectQL + aggregate paths were affected. The symptom is #3650's: a chart that draws the + whole dataset instead of the requested window, with nothing in the SQL to + suggest a filter was ever asked for. + + `$between [min, max]` now lowers to its two bounds (`gte` + `lte`) instead of + gaining an operator of its own, so a range's max inherits the calendar-day + whole-day rule (#3777) from each strategy's existing upper-bound handling — + `NativeSQLStrategy` compiles a bare-day upper bound half-open itself, and the + ObjectQL path gets the same rule from the driver — rather than needing a second + implementation to keep in step. A malformed `$between` (not a two-element + array) now throws instead of being dropped, matching the stance driver-memory + took for the same shape in #3948: an unbounded read is exactly the failure this + prevents, and it is indistinguishable from a legitimately wide query. + + Found by giving the temporal conformance matrix its missing sixth consumer + (`native-sql-temporal-conformance.test.ts`), which executes the shared cases + against a real SQLite engine and asserts row ids — a dropped predicate is + invisible to the SQL-string assertions the strategy's other suites use. + +- 7a55913: fix(service-analytics): every authorable filter operator now reaches the query (#4128) + + Closes the cause behind the `$between` defect rather than just that instance. + `normalizeAnalyticsFilters` skipped any operator missing from its map, and a + skipped predicate does not narrow a query — it **widens** it: the compiled SQL + stays valid and returns rows the author excluded. Four operators from the + spec's authorable vocabulary sat in that state, plus one that was mapped + incorrectly. + + - **`$startsWith` / `$endsWith`** were dropped entirely. Both strategies now + compile them — anchored `LIKE 'x%'` / `LIKE '%x'` on the raw-SQL path, and + the canonical `$startsWith` / `$endsWith` operators (which every driver + implements directly) on the ObjectQL path, so an anchored match does not + depend on regex dialect. + - **`$null`** was dropped. It is the shape the console emits for an "is empty" + / "is not empty" filter, so such a widget was showing every row. Now compiles + to `IS NULL` / `IS NOT NULL` per its boolean. + - **`$exists`** was mapped value-_independently_ to `set`, so `{$exists: false}` + compiled to `IS NOT NULL` — the exact inverse of what it asks for. It and + `$null` are now resolved explicitly, because a key→name map cannot express an + operator whose meaning flips with its value. + - **`$notContains`** reached the ObjectQL strategy, which had no arm for it and + fell through to a `default` returning a bare value — compiling "does not + contain x" as "**equals** x". + - **Unknown operators now throw** on both surfaces instead of being silently + dropped (normalizer) or reinterpreted as an equality (ObjectQL strategy). An + operator outside the vocabulary is a caller error, and a loud one beats a + silently widened read — the call driver-memory made for the same shape in + #3948. + + Still declared as a gap, but no longer a silent one: `$or` / `$not` are skipped, + since expressing them needs a recursive WHERE builder rather than the flat + array the strategies consume. + + Cover is `filter-operator-coverage.test.ts`, which runs the whole vocabulary + against a real SQLite engine and asserts **row ids** — six of its cases fail + without this change. A dropped predicate is invisible to the SQL-string + assertions the strategies' other suites use, which is how these survived. + +- f5ab1c7: fix(service-analytics): a `$or` / `$not` filter no longer vanishes from an analytics query (#4128 follow-up) + + The last of the silently-dropped filter family. `normalizeAnalyticsFilters` + produced a flat **array**, which cannot carry a disjunction, so both strategies + skipped `$or` and `$not` outright — a widget or dataset whose filter used + either compiled a WHERE clause that simply did not contain it, and drew every + row. That is #3650's symptom, and unlike a rejected query it looks like a + working chart. + + The normalizer now produces a **tree** (`normalizeAnalyticsFilterTree`), and + each strategy compiles it the way its own backend expresses a disjunction: + + - **`NativeSQLStrategy`** builds the WHERE recursively, routing every leaf + through its existing clause emitter — so the storage-form coercion and the + calendar-day upper-bound rule (#3777) apply at every depth, including inside + an `$or`. Parentheses are explicit rather than relying on SQL precedence. + - **`ObjectQLStrategy`** hands `$or` / `$not` to the engine, which speaks them + natively. AND-ed leaves still merge per field exactly as before, so a query + without combinators produces byte-identical engine input. + - **`/analytics/sql`** renders the same tree, so the echoed statement keeps + reproducing what executes rather than showing a conjunction where the engine + runs a disjunction. + - The **cross-object envelope check** now sees members nested inside an `$or`. + It rejects cross-object filters, so a member it could not see was a filter it + could not reject. + + Empty `$and` / `$or` arrays now throw instead of being ignored, matching the + fail-closed stance of `read-scope-sql.ts` — the compiler in this same package + that has always handled the full tree, and whose semantics the tree walker now + mirrors deliberately. + + Cover is `native-sql-filter-logic-conformance.test.ts`, which runs the shared + combinator table (`FILTER_LOGIC_CASES`, #3774) against a real SQLite engine and + asserts row ids. The analytics raw-SQL path now stands beside `driver-sql`, + `driver-memory`, `formula` and `read-scope-sql` under that one standard; 14 of + its 17 cases fail without this change. + +- 3abd233: fix(analytics): project a `timeDimensions` bucket into the result rows and fields (#4033) + + An analytics query that buckets by `timeDimensions` alone grouped correctly — + the echoed SQL read `date_trunc('month', due_date) AS "due_date"` — but the row + mapper and `buildFieldMeta` both enumerated `query.dimensions` only, so the + bucket never reached the caller: rows carried just the measures and `fields` + never mentioned the dimension. A trend chart got N values and no x-axis. The + same query written with `dimensions: ['due_date']` was unaffected, which is why + it went unnoticed. + + Grouping, row mapping and field metadata now derive the projected set from one + `projectedDimensions()` helper — `dimensions` plus every _granular_ + `timeDimensions` entry not already among them. A `timeDimensions` entry without + a granularity contributes only its `dateRange` predicate and stays out of the + projection, so no phantom column is declared. + +- 0af50a3: fix(driver-sql,service-analytics): a bare-day upper bound covers the whole day on `Field.datetime` (#3777) + + A bare `YYYY-MM-DD` comparand anchors to midnight UTC. That is right for a + lower bound and was silently wrong for an upper one: the dashboard date-range + filter compiles `{ $gte: from, $lte: to }` with bare-day bounds, so on a + `datetime` column every row created after 00:00 of the `to` day vanished from + the result — no error, the chart renders, the numbers are just smaller. The + default configuration hit it: the filter's default field is `created_at` + (a system-injected `Field.datetime`) and 7 of the 13 presets end "today". + + The translation is operator-sensitive and half-open, applied at every + comparison emitter: + + - `SqlDriver` (and `SqliteWasmDriver` by inheritance): `$lte`/`<=` with a + bare-day comparand on a `datetime` column compiles to `< next-day-midnight` + in the column's storage form; `$between [min, max]` with a bare-day max + decomposes to `>= min AND < next-day(max)`. Both the plain and the + legacy-repair (mixed-storage) column paths, both `where` spellings. + - `NativeSQLStrategy`: `dateRange` windows and `lte` filters bind `< next-day` + instead of an inclusive `BETWEEN`/`<=` when the bound is a bare day. + - The `/analytics/sql` rendering and the dataset preview evaluator apply the + same rule, so the echoed SQL and drafted numbers reproduce execution. + + `@objectstack/core` gains the shared primitive `nextUtcCalendarDay(value)`: + the next calendar day of a valid bare `YYYY-MM-DD` (else `null` — instants, + `Date`s and impossible days are never widened). + + Unchanged on purpose, per the semantics table on #3777: `date`/`time` columns + (`<= day` is already whole-day-correct there), full-ISO/`Date` comparands + (instant semantics), and `$gte`/`$gt`/`$lt` (midnight anchoring is correct for + those). No authored metadata changes: a dashboard's existing + `{ $gte, $lte }` window now simply includes its final day. + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- c8124e5: fix(driver-sql): give `Field.datetime` one UTC storage form per dialect (#3912, #3942) + + Any window filter on a `Field.datetime` column returned an empty set on SQLite — + a dashboard `dateRange: last_30_days` on `created_date` read 0 while 29 matching + rows existed. + + There was never a storage _convention_, only a description of what better-sqlite3 + happened to do with a bound JS `Date`. Nothing enforced it — `formatInput` + deliberately left `datetime` untouched — so the form was decided by whichever + writer got there first: a JS `Date` landed as INTEGER epoch ms, while a REST/JSON + write (JSON has no `Date` type), a `defaultValue: 'NOW()'` slot, and the + platform's own `created_at` / `updated_at` all landed as ISO **TEXT**. One column + held both forms while the read path coerced comparands to epoch ms purely from + the _declared_ type. On SQLite's type ordering (`INTEGER < TEXT`) a two-sided + window collapsed to zero rows, and a one-sided `>=` matched every TEXT row + regardless of the bound. + + `Field.datetime` now has one canonical instant per dialect, produced by one + function applied on write **and** to every filter comparand, so the two sides of + a comparison cannot disagree about shape: + + - **SQLite** — `YYYY-MM-DDTHH:MM:SS.sssZ` text. Lexicographic order _is_ + chronological order, so range filters and `ORDER BY` read the column directly + and can use an index; `strftime` parses it, so the date-bucket expression needs + no CASE. + - **Postgres** — `timestamptz`, unchanged. The fix here is on the write and + comparand side: a zone-naive write was previously resolved against the + _server's_ timezone (measured 8 hours off on `Asia/Shanghai`), and an + un-anchored `YYYY-MM-DD` comparand meant the server's local midnight, so the + identical query over the identical instant landed a row on a different calendar + day than SQLite did. + - **MySQL** — `DATETIME(3)` instead of `TIMESTAMP`, a connection pinned to UTC on + both the mysql2 and the server layer, and a MySQL-spelled bind carrying the + same UTC wall clock. MySQL accepts neither the `T` separator nor the `Z` suffix + in a datetime literal, so datetime writes over REST had always failed outright; + `TIMESTAMP` additionally truncated milliseconds and could not store an instant + outside 1970..2038. + + Existing rows converge at schema sync. Both migrations are allowed to fail: they + log, mark nothing, and the read paths keep a repair expression, so an un-migrated + column still compares and buckets **correctly** — just unindexed. Neither can + repair instants the old timezone-ambiguous write path recorded wrongly; they + preserve what is on disk. + + Also closes #3928 (datetime `ORDER BY` mis-sorted on mixed storage) by + construction. Rationale is recorded as ADR-0053 addendum D-B1..D-B4. + + The analytics change is additive: a `coerceTemporalFilterColumn` companion to the + existing `coerceTemporalFilterValue` hook, so a raw-SQL strategy can normalise the + column side too. Absent hook → byte-identical SQL. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- f752ee3: feat(analytics): order the time axis by default, and give reports a sort declaration (#3916) + + A matrix report with a date dimension across rendered its columns in arbitrary + order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on + the dataset dimension made the bucket keys _sortable_ (`2026-07`, `2026-Q3`) + without making anything _sort_ them, and the report author had no way to ask: + `DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering + field at all (dashboard widgets had their own `options.sortBy` channel; reports + did not). Nothing in the chain supplied an order either — `resolveOrdering` + returned `undefined` unless the selection carried one explicitly, the ObjectQL + aggregate path has no ordering grammar so its buckets came back in Map-insertion + order, and the pivot builds its column headers in row-arrival order. + + - **A selected time dimension is now chronological by default.** When a + selection states no `order` (and no `limit`, whose own fallback already + ordered by every dimension), each selected dimension the cube types as `time` + defaults to ASCENDING, in selection order. Bucket keys are minted sort-stable + precisely so this works — `2026-07` sorts after `2026-06`, `2026-Q3` after + `2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native + SQL serves the query, and the executor's post-pass where a date-bucketed query + is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere + else. Deliberately narrow: only time dimensions get a default, so grids with + nothing wrong with them are not reordered. + - **Reports can declare an ordering.** `ReportSchema.order` (and + `blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort + keys, most significant first — an array, not a `Record`, because key order is + the contract and JSON object key order should not have to be. `by` must name a + dimension the report groups by (`rows` / `columns`) or a measure it displays + (`values`); anything else fails at authoring time rather than becoming an + ordering that silently does nothing. Duplicate keys are rejected. A `joined` + report orders per block — declaring `order` on the container is an error. + `reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a + renderer posts, and returns `undefined` for an empty list so the runtime's own + defaults still apply. + + An explicit `order` still wins outright — the chronological default is a + default, not a policy, so "newest month first" is one declaration away. + + `report.order` ships as `planned` + `authorWarn` in the liveness ledger: the + framework half is complete and live (schema, lowering helper, executor), but + objectui's `DatasetReportRenderer` does not yet carry `report.order` into the + selection it posts. The default time-axis ordering needs no renderer change and + is live now. + +- b3a3d83: feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081) + + `@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the + single set of temporal filter cases every backend is checked against, the twin + of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row + results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and + MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview + evaluator, and `formula`'s RLS write-side `check`. + + This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and + the last of its decisions to be actioned. Four separate incidents — #3650, + #3773, #3777, #4047 — were each found by a human by accident, and each left a + suite proving only its own issue against its own fixture. Nothing held the + backends to one standard, so the fifth divergence had nowhere to fail. + + **`service-analytics` — a real fix the matrix found on its first run.** The + draft-preview evaluator had no `$between` case, so it fell through to its + permissive `default` and matched **every** row: a drafted dashboard carrying a + range filter charted the entire dataset, then changed its numbers at publish — + the exact continuity the preview exists to provide. It now evaluates + `$between`, sharing the upper-bound helper with `$lte` so the whole-day + calendar-day rule (#3777) applies to a range's max as well. + + Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a + `datetime` column cannot agree between typed and type-blind backends, and the + gap is irreducible without field types. It is asserted in the shared matrix on + `date` only, with the `datetime` cell left to the typed drivers' own suites, + rather than papered over. + +- 35accbf: feat(spec): promote the temporal storage hooks onto the IDataDriver contract (ADR-0053 D-A2) + + `temporalFilterValue` and `temporalFilterColumnSql` — the pair that closed + #3912's storage-form drift — were duck-typed: analytics probed + `typeof driver.x === 'function'` against a locally-invented interface, and + nothing at the type level said a driver must implement both or neither. The + lesson of #3912 is precisely that coercing the comparand without normalising + the column reintroduces half the bug, so a driver implementing one hook alone + would silently regress. + + Both are now optional members of `IDataDriver` + (`@objectstack/spec/contracts`), documented as a pair with "absent = identity" + semantics for drivers whose storage form is the wire form (memory, mongo). + `SqlDriver implements IDataDriver`, so its signatures are compile-checked from + here on; analytics derives its driver seam by `Pick`-ing the contract instead + of a local duck type. Runtime `typeof` guards remain — that is the correct way + to consume an optional contract member — but the shape they guard now has one + authoritative definition. + + No runtime behaviour change. ADR-0053 D-A2 is recorded as resolved. + +- e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two + type-blind filter backends (ADR-0053 D-A3 / #4191). + + `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data` + (re-exported from `@objectstack/core`): it reads the UTC instant a temporal + operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a + bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a + zone-naive one being UTC, per D-B2) — and returning `null` for everything + else, notably a bare wall clock, which denotes no instant. + + Both type-blind evaluators now use it to compare a `Date` against wire text, + which JS relational operators cannot do: `<` and friends coerce with hint + `number`, so the `Date` becomes its epoch and the string becomes `NaN`. + + - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped + every `Date`-valued row in 10 of the 16 shared conformance cases. The + post-image is the caller's raw write payload, so an SDK write of + `new Date()` hit this directly, and fail-closed turned it into a **denied + write**. + - `service-analytics`' preview evaluator diverged on the same 10 cases in + BOTH directions, because `String(new Date())` sorts after every `'2026-…'` + comparand — a drafted chart both lost rows and gained ones, then changed + its numbers at publish. Rows from a mongo-backed dataset arrive as BSON + `Date`s, so this was reachable in normal use. + + Comparisons that did not involve a `Date` are unchanged. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 6204ea9a12..52dd1c27af 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 011b0919fb..f62beafad4 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,1167 @@ # @objectstack/service-automation +## 17.0.0-rc.1 + +### Major Changes + +- a648e96: fix(spec,service-automation)!: `errorHandling.maxRetries` has one default, and `strategy: 'retry'` states its count (#4247) + + `flow.errorHandling.maxRetries` was declared twice, with different values: + + - **spec** — `FlowSchema` (`automation/flow.zod.ts`): `.default(0)` + - **engine** — `retryExecution` (`service-automation/src/engine.ts`): + `errorHandling.maxRetries ?? 3` + + `??` fires only on `undefined`, so the winner was decided by the ROUTE a flow + took into the engine, not by what its author wrote: + + | Path | `errorHandling.maxRetries` | Retries | + | :---------------------------------------------- | :------------------------- | ------: | + | parsed by `FlowSchema` (`.default(0)` fills it) | `0` | **0** | + | object built by hand and fed to the engine | `undefined` | **3** | + + One authored intent — "I didn't write a count" — two behaviors. The neighbouring + `retryDelayMs ?? 1000` / `backoffMultiplier ?? 1` agreed with their `.default()`s; + only `maxRetries` disagreed, which reads as a schema default changed from 3 to 0 + without the engine following, not as a deliberate two-track design. + + **The engine keeps no defaults of its own.** `retryExecution` now takes the + parsed `NonNullable` and destructures all five + knobs — no `??`. This is safe because `AutomationEngine.flows` only ever holds + `FlowSchema.parse` output (`registerFlow` parses; the version-history rollback + re-seats an already-parsed snapshot), and it is what keeps a second set of + defaults from growing back: a knob the spec stops defaulting becomes a compile + error rather than a silent engine-side guess. Per Prime Directive #12 the spec + is the one contract; a consumer-side fallback is a second de-facto one. + + **BREAKING — `strategy: 'retry'` now requires `maxRetries` >= 1.** With the + engine's copy gone, an unstated count is unambiguously `0`, and `'retry'` with 0 + attempts runs the flow once and stops — i.e. `strategy: 'fail'` wearing another + label, a declared capability the runtime does not deliver (Prime Directive #10 + corollary). Rather than pick 0 or 3 on the author's behalf, `FlowSchema` refuses + the combination in both spellings (omitted → defaulted 0, and an explicit 0), + with the prescription in the message. A retry re-runs the **whole flow from the + start** — records created again, callouts fired again — which is not a number to + guess for someone. + + FROM → TO: + + - `errorHandling: { strategy: 'retry' }` → `errorHandling: { strategy: 'retry', maxRetries: 3 }` + (or `strategy: 'fail'` if no retry was intended — that is what it did). + - `errorHandling: { strategy: 'retry', maxRetries: 0 }` → same choice, spelled out. + + Unaffected: `maxRetries: 0` under `strategy: 'fail'` / `'continue'` (neither + reads it, and a fully spelled-out block stays legal), flows with no + `errorHandling` at all, and every flow that already states a count — including + the `try_catch` node's own `config.retry`, which is a separate per-region policy + (`control-flow.zod.ts`) and is unchanged. + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 62a789b: Reconcile the remaining flat builtins' declared config against what their + executors read (#4045 — the CRUD / screen / map step, after notify / http / + connector in #4210). + + **Six executor-derived Zod contracts.** `GetRecordConfigSchema`, + `CreateRecordConfigSchema`, `UpdateRecordConfigSchema`, + `DeleteRecordConfigSchema`, `ScreenConfigSchema` (+ `ScreenFieldConfigSchema`) + and `MapConfigSchema` in `automation/builtin-node-config.zod.ts`, each written + by reading the executor rather than transcribing the descriptor literal, so the + new bidirectional ledger test is evidence rather than a tautology. Contract + exports only — nothing parses with them yet (#4045 step 3b, gated on the #4059 + warning data). + + **Seven capabilities the executors honour are now authorable.** Each was read + by the executor and offered by no form — online or offline — so it was reachable + only by hand-written metadata: + + - `get_record.fields` — the query projection, passed straight into + `find`/`findOne`; + - `screen.recordId` — the record `mode: 'edit'` opens; the form declared the + edit mode while offering no way to name its target; + - `screen.fields[].options` / `defaultValue` / `placeholder` — all three + forwarded into the ScreenSpec the client renders, so a select field's choices + could not be authored in Studio at all. Same nested repeater position as the + `visibleWhen` gap #3528 was filed for; + - `map.indexVariable` and `map.input` — the index binding and the per-item + subflow params. + + **`map`'s undeclared `flow` alias graduates to the conversion layer.** The + executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever + described — the `notify.source` shape (Prime Directive #12). The bare fallback + is deleted and `flow-node-map-flow-alias` (protocol 17, retires at 18) renames + it at load, including the `AutomationEngine.registerFlow` rehydration seam. + + **`assignment` is pinned as deliberately un-reconcilable**, with the reason on + record: with no `assignments` wrapper its top-level config keys ARE the author's + variable names, so no fixed key set can describe it and a catchall Zod would + reconcile vacuously. What the ledger pins instead is that the form offers + exactly the canonical `assignments` map and that the map stays open. + + With this, every builtin that publishes a `configSchema` is reconciled against + its executor, and the ones that publish none each have a recorded reason. + +- b07d829: feat(automation,spec): flow executors `parse()` their config, and undeclared config keys reject at registration (#4277) + + The #4045 reconciliation left every flat builtin with a Zod config contract that + nothing enforced, and #4059 left `registerFlow` warning about undeclared keys it + could not yet safely reject. #4277 installs both halves of the enforcement: + + **1. Executors parse their config (execute time).** The 12 contract-carrying + builtins — `get_record` / `create_record` / `update_record` / `delete_record`, + `screen`, `map`, `notify`, `http`, `loop` / `parallel` / `try_catch` — now run + `node.config` through their Zod contract before executing + (`service-automation/builtin/parse-config.ts`). A type or missing-`required` + violation refuses the node as a **guard** (`errorClass: 'guard'`, not routable + via `fault` edges — config is metadata; re-running changes nothing), naming + every violated path. `{token}` templates stay legal: string-typed slots parse + the raw template, and `http` — whose executor reads the interpolated config — + parses POST-interpolation, where a whole-token template has already resolved to + its value's real type. Exemption: a legacy flat-graph `loop` (no `config.body`) + predates the ADR-0031 construct and is not parsed. + + **2. Undeclared config keys are rejected at `registerFlow` (registration + time).** The #4059 warning is now an error: a config key the node type's + descriptor `configSchema` does not declare fails registration, with the exact + path, the declared key set, a did-you-mean, and — for keys with documented + history (`screen.visibleIf`, `create_record`/`update_record.fieldValues`) — a + per-key tombstone (the `UNKNOWN_KEY_GUIDANCE` pattern). Unchanged exemptions: + `assignment` is exempt wholesale (its top-level keys ARE the author's variable + names), schemaless types (`decision`/`script`/`wait`/`subflow`/ + `connector_action`) declare nothing so nothing can be undeclared, and keyValue + maps stop the walk (their keys are author data). Every `registerFlow` call site + already try/catches per flow, so a bad stored flow is skipped loudly at boot, + never a crashed kernel. + + **Contract fix folded in:** `LoopConfigSchema.collection` is now + `z.union([z.string().min(1), z.array(z.unknown())])` — the executor has always + accepted an inline array (shared resolve logic with `map.collection`, which + already declared the union), so the string-only declaration under-declared what + it reads. + + **Migration.** If a flow stops registering: the error names the undeclared key + and its path — rename it to the declared key it meant (`visibleIf` → + `visibleWhen`, `fieldValues` → `fields`), or delete it (an undeclared key was + never read, so removing it changes no behavior). If an executor of yours + genuinely reads the key, declare it on the node type's descriptor + `configSchema`. If a node starts refusing at run time: the refusal names each + violated path against the contract — fix the value's type or supply the missing + required key (e.g. `get_record` `limit` must be a number; `screen` + `fields[].options` entries are `{ value, label }` objects; `notify` requires + `recipients` + `title`). Retry-policy defaults now come from the contract: a + `try_catch` `retry` block that omits `retryDelayMs` gets the documented 1000ms + base delay where the executor historically used 0. + +- a47ac06: feat(spec,automation): graduate the seven flow-node config key aliases into the conversion layer — the `readAliasedConfig` shim retires with them (#3796) + + `FlowNodeSchema.config` is an unconstrained record, so the executors were the + only statement of which config key is canonical — and seven deprecated aliases + lived there as tolerance the spec never declared: one behind the + `readAliasedConfig` deprecation shim (warned, ledgered), six as open-coded + `??` fallbacks (no warning, no ledger, no retirement path). All seven now + graduate into the ADR-0087 D2 conversion layer as protocol-17 **live-window** + entries: a stored flow authored with an alias is rewritten to the canonical + key at load — `defineStack` / `validate` / `lint` and the + `AutomationEngine.registerFlow` rehydration seam alike — with a structured + `ConversionNotice` per rewrite, and the executors read the canonical keys + only. The shim (`service-automation/src/builtin/config-aliases.ts`) is empty + and deleted. + + FROM → TO (per node type; conversion entry in parentheses): + + - `get_record`/`create_record`/`update_record`/`delete_record`: + `config.object` → `config.objectName` (`flow-node-crud-object-alias`) + - `notify`: `config.to` → `config.recipients`, `config.subject` → + `config.title`, `config.body` → `config.message`, `config.url` → + `config.actionUrl` (`flow-node-notify-config-aliases`) + - `script`: `config.functionName` → `config.function`, `config.input` → + `config.inputs` (`flow-node-script-config-aliases`) + + One-line fix: rename the key in your flow source — values are unchanged; `os +migrate meta --from 16` rewrites all seven mechanically. Until then nothing + breaks: the protocol-17 loader accepts and converts the old shape (window + retires in 18). + + `actionUrl` (not `url`) is the deliberate canonical of its pair, resolving a + contradiction where the notify descriptor documented `url` as canonical while + the executor, tests, and examples preferred `actionUrl`: the whole downstream + chain already uses that name (`sys_notification.action_url`, the + channel-dispatch contract, the REST notification read model), and `url` + elsewhere in the platform means "HTTP endpoint to call" (`http` node, + webhooks) — a different concept from this in-app click-through target. The + executor precedence already put `actionUrl` first, so the choice is + behaviour-preserving; the `notify` descriptor's `configSchema` now documents + `actionUrl`. + + Callers that hand a node config **directly** to an executor (bypassing + `registerFlow`) no longer get alias resolution — build the config with the + canonical keys. + +- e4c61a7: Validate the expression slots a flow node's `configSchema` declares (#4027). + + A node type's designer `configSchema` and the keys its validators traverse were + two unreconciled lists. Both the engine's `registerFlow` pass and the author-time + `objectstack validate` pass hardcoded `config.condition` / `edge.condition` and + assumed every other node string was a `{var}` template — so a declared expression + property outside that hardcoded set was validated by nobody. + + That is how #3528 shipped. `screen.fields[].visibleWhen` has been on the `screen` + descriptor since #3304, typed `xExpression: 'expression'` (bare CEL) and offered + to authors in Studio, but no validator traversed it. An app authored the + predicate in the _other_ dialect — `'{createOpportunity} == true'` — and it passed + `tsc`, `objectstack validate` and registration in silence. Because `required` _is_ + enforced, a field the author had made conditional rendered unconditionally and + blocked Submit on an input the user was never shown: the run paused forever and no + resume was ever issued. + + Now: + + - **`FLOW_NODE_EXPRESSION_PATHS`** (`@objectstack/spec`) is the declared ledger of + expression-bearing node config paths, each recording the dialect it takes. + - **Both validators read it.** A malformed `visibleWhen` is a located, quoted + error at `registerFlow` _and_ at `objectstack validate` — `node 'screen_1' +(screen) screen field visibleWhen at config.fields[1].visibleWhen`. + - **A reconciliation ratchet** derives the expression properties from the live + descriptors and fails CI in both directions: a new `xExpression` property with + no ledger entry, or a stale entry no descriptor declares. It walks every + registered builtin, not just `screen`. + + Dialects are recorded rather than assumed because there are three, and two of them + disagree about braces: bare CEL (`{…}` is the #1491 brace-trap), single-brace + `{var}` flow interpolation (`{…}` is correct), and the ADR-0032 §3 double-brace + text template. Only bare-CEL slots are checked — `loop.collection` and + `map.collection` are recorded as `flow-template` and deliberately left alone, + since no validator implements their dialect and checking them under either of the + other two would reject every currently-valid flow. + + `ActionDescriptor.configSchema`'s TSDoc no longer claims `registerFlow()` + validates `config` against it. It never did: `FlowNodeSchema.config` is + `z.record(z.unknown())`, so types, `required`, `enum` and unknown keys are still + unenforced. The doc now states exactly what is checked and what is designer-facing + only, so nothing relies on a guard that does not exist. + +- 081aa6f: feat(spec,service-automation): every flow run reports what it actually did — selected / acted / skipped (#4354) + + `success: true` never meant "it did its job". A scheduled sweep that selects + thirty records and writes none is, from outside, **identical** to one with + nothing to do: same green status, same empty output, same silence, same schedule + tomorrow. There was no signal anywhere that separated "nothing to do" from + "broken". + + That is not theoretical. #4347 left three hotcrm production flows completely + inert — the stalled-deal sweep found every stalled deal and nudged nobody, the + renewal sweep booked nothing, the campaign action enrolled no leads. They ran + daily, on time, green, for as long as they had existed, and were caught only by + adding tests that assert on records written. Automation is exactly the category + where nobody is watching: a UI bug files a ticket within the hour, a dead sweep + files nothing, and the longer it runs the more normal the silence looks. + + **Every terminal run now carries a `FlowRunSummary`** — on the + `AutomationResult`, on the run in `listRuns` / `getRun`, in the log, and in the + database: + + ``` + [automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30 + ``` + + - `selected` — records read by the run's data nodes + - `acted` — records created / updated / deleted, plus effects dispatched + (notifications delivered) + - `skipped` — node executions a closed gate prevented, one per loop iteration + whose conditional edge evaluated false + - `nodes[]` — per-node terminal status with `runs` / `failures` / `skipped` + - `gates[]` — which gates closed and how often, most-skipped first + + **The counts are declared, not sniffed.** Executors report + `NodeExecutionResult.metrics`, because only the node knows what its result + _means_: `update_record`'s is a row count on a bulk write and a record on a by-id + one, `delete_record`'s can be a boolean, `notify`'s is a delivery count. An + engine inferring from output shapes would be guessing, and a machine-readable + count that guesses is worse than none. A node that touches no records + (`decision`, `assignment`) reports nothing — absent is not `0`. + + **The gate is named.** A conditional out-edge that evaluates false now records a + `skipped` step tagged with the gate that closed. That event previously left no + trace at all, which is why #4347 was invisible: the flow selected every row and + the loop-body edge never opened. A skipped step is explicitly _not_ a run — the + ADR-0044 re-entry guard, per-node `runs`, and node status all exclude it, so a + new observability signal cannot change execution semantics. + + **Queryable, so it can be alerted on rather than noticed.** + `sys_automation_run` gains `selected_count` / `acted_count` / `skipped_count` + columns plus a `summary_json` breakdown: + + ```typescript + const suspect = await engine.find("sys_automation_run", { + where: { status: "completed", selected_count: { $gt: 0 }, acted_count: 0 }, + orderBy: [{ field: "started_at", order: "desc" }], + }); + ``` + + `selected > 0 && acted == 0` over consecutive runs is a near-perfect + broken-sweep detector. Columns, not JSON: an operator can only alert on what is + filterable. Rows written before this carry `null`, never `0` — "not measured" + must not read as "measured zero", or every legacy row is a false alarm the first + time someone writes that query. + + Two details that decide whether the numbers can be trusted. The summary is + folded from the **full** step log before history compaction, so a + 5000-iteration sweep does not silently report the ~200 steps that fit in + `steps_json`; and rehydration reads the persisted `summary_json` rather than + re-folding those compacted steps. A `subflow` rolls its child's totals into its + parent, so a sweep that delegates its writes is not read as inert — the child + keeps its own run row, and the parent's summary answers "what did this run + cause". + + Additive throughout: `summary` is optional everywhere it appears, existing runs + and stores keep working, and no execution behaviour changes. The one-line log + defaults to `info` — a line nobody sees at their production level is the same + non-signal this closes — with `AutomationServicePlugin`'s + `runSummaryLog: 'debug' | 'off'` to turn the volume down on a very + high-frequency flow without turning the measurement off. + + New spec exports: `FlowRunSummarySchema`, `FlowRunNodeSummarySchema`, + `FlowRunGateSummarySchema`, `ExecutionStepMetricsSchema`, + `ExecutionStepSkipReasonSchema` (+ inferred types); `ExecutionLog.summary` and + `ExecutionStepLog.metrics` / `.skippedBy`. `service-automation` exports + `summarizeRun` / `formatRunSummaryLine` so a host building its own surface + reuses the platform's definition instead of re-deriving one. + + Does not fix #4347 itself — this is the instrument that would have caught it. + + Verified: `@objectstack/service-automation` **522 tests / 46 files** (23 new), + `@objectstack/spec` **7165 / 279** (5 new), `@objectstack/runtime` **974 / 68**, + `@objectstack/plugin-approvals` **330 / 13**; all eight `@objectstack/spec` + `check:generated` gates plus `check:liveness` and `check:exported-any`; and + `tsc --noEmit` on service-automation at its ledgered 2 pre-existing errors. + +- d25a0ec: feat(spec,service-automation): a run says when its `acted` count is incomplete, instead of guessing (#4354) + + #4354 shipped `selected` / `acted` counts on every flow run, sourced from the + executors that know what they did. Four node types were left out — and the gap + was not cosmetic: `connector_action`, `http` and `script` are how a flow acts on + anything _outside_ the platform, so a sweep whose whole job runs through them + reported `acted: 0` and looked exactly like the dead sweep the counter exists to + find. A detector that fires on healthy runs is worse than no detector: operators + tune it out, and then it is not watching the flows that really did stop. + + Closing it needed a third answer, because for two of those nodes the platform + genuinely cannot know: + + **`connector_action` — unknowable, and now it says so.** + `ConnectorActionDescriptor` declares `key` / `label` / `description` / + `inputSchema` / `outputSchema` and _nothing_ about whether the action reads or + writes, so `crm.push_opportunity` and `crm.lookup_account` are the same shape to + the runtime. `acted: 0` understates the create; `acted: 1` overstates the + lookup and makes the alert never fire — #4354's original bug, one layer out. + The executor reports `metrics: { unmeasuredEffect: true }` instead, and the run + carries an `unmeasured` tally. Filed #4395 to let a connector declare its effect + kind, which would turn this into a real count. + + **`http` — knowable, and now counted.** The method says it: + `GET`/`HEAD`/`OPTIONS` report a real `acted: 0` (a read cannot write); a mutating + call the upstream accepted reports `acted: 1`; `durable: true` reports `acted: 1` + because the outbox row is a durable effect this run caused. A mutating call that + was _rejected or timed out_ reports `unmeasured` — a 500 can arrive after the + write landed, and claiming zero there would let a run swear it changed nothing + when it had. + + **`script` — deliberately unchanged.** A registered function is contractually + pure ("Data I/O stays on the flow graph — the function itself does no writes"), + so every write it causes is a downstream node counting itself and "reports no + record metrics" is accurate rather than a guess. Nothing _enforces_ that purity, + so a function that writes behind the platform's back under-reports its run — + filed as #4396 rather than papered over here, because a blanket + `unmeasuredEffect` on `script` would suppress the signal on every flow that + calls any function in order to accommodate one contract violation. + + **The alert gains a clause.** `selected > 0 AND acted = 0` becomes + `selected > 0 AND acted = 0 AND unmeasured = 0`, and `sys_automation_run` gains + an `unmeasured_count` column to serve it. Without that third clause the alert + fires on every healthy connector-driven flow. The log line gains + `unmeasured=N` — only when non-zero, since its _presence_ is what a reader must + not miss: `acted=0` on a line that also says `unmeasured=3` means "cannot tell", + not "did nothing". + + `unmeasured` propagates through `subflow` and `map` roll-ups (and through + `creditChildRun` for a child that paused), so a parent whose child dispatched an + uncountable effect knows its own `acted` is incomplete. N uncountable effects in + a child collapse to one flag on the parent's step — the child keeps the real + count in its own run row, and the question this feeds is boolean. + + `FlowRunSummary.unmeasured` is optional and `undefined` is **not** `0`: a run + recorded before this existed did not track uncountable effects at all, and + defaulting it to zero would tell an operator "fully measured" about a run nobody + measured. Same rule the `null` count columns already follow. + + Additive: new optional fields only, no new exports, no execution behaviour + changes. + + Verified: `@objectstack/service-automation` **546 tests / 47 files** (21 new), + `@objectstack/spec` **7193 / 281** (2 new); all 8 `check:generated` gates plus + the seven pure audits (liveness, empty-state, variant-docs, strictness-ledger, + react-conformance, skill-examples, exported-any); `check:nul-bytes` and eslint + clean. + +- 4965bfa: Warn on flow-node `config` keys the node type does not declare (#4045). + + `FlowNodeSchema.config` is `z.record(z.unknown())`, so a misspelled or invented + config key was accepted in total silence: `visibleIf` instead of `visibleWhen` + registered cleanly, was never read, and the only symptom was a feature that quietly + did not happen. That diagnostic vacuum is what made #3528 take three passes and two + wrong diagnoses to resolve. + + `registerFlow` now compares each node's `config` against its descriptor's + `configSchema` and warns on anything undeclared, located and with the declared set + listed: + + ``` + [flow 'lead_conversion'] node 'screen_1' (screen): unknown config key `visibleIf` + at config.fields[0].visibleIf — It is not declared by this node type's + configSchema, so nothing reads it. Declared here: name, label, type, required, + visibleWhen. + ``` + + The walk descends where the schema declares structure and **stops at free-form + keyValue maps**, whose keys are author data (`filter: { status: 'stale' }`). + Descending matters: the #3528 typo class lives _inside_ the `screen` field + repeater, so a top-level-only comparison would miss the exact mistake this exists + to catch. + + **Warn, never reject.** An undeclared key is an author typo, a key the executor + genuinely reads that its hand-written `configSchema` never declared (`notify.source` + was exactly this), or dead config. Only 4 of the 13 schema-carrying builtins have + been audited for the second population, so hard-failing would gamble on the other + nine. Tightening to an error is a later, per-key decision once this warning has + measured the real distribution. Nothing about the published `configSchema` changes, + so no consumer sees a different shape. + + `@objectstack/formula` now exports `nearestName`, the edit-distance helper already + used for unknown-field and unknown-role suggestions, so "did you mean?" + diagnostics share one threshold. It is deliberately a bonus rather than the + mechanism — `visibleIf` → `visibleWhen` is distance 4 against a threshold of 3, so + the declared set is always listed instead of only as a fallback. + + Also fixes the first real finding from the new check: `showcase_inquiry_purge`'s + `get_record` node carried `mode: 'records'`, which no executor reads, with a comment + crediting it for behaviour that `limit > 1` actually produces. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 41dcda3: fix(spec,runtime,service-automation): `IAutomationService` declares the connector registry it already serves (#4127) + + The fourth and last of the dispatcher call sites #4127 found calling a method its + contract never declared. The first three shipped in #4143; this one was held back + because the fix is a **type move**, not a type addition — `ConnectorDescriptor` + was declared in `@objectstack/service-automation`'s engine, which is one + _implementation_ of `IAutomationService`. A contract cannot name a type that + lives inside its own implementation, so `getConnectorDescriptors` could not be + declared at all until the type had a home in the spec. + + **`IAutomationService` += `getConnectorDescriptors?()`.** It is the sibling of + `getActionDescriptors`, which the contract has declared since ADR-0018: the two + fill the flow designer's `connector_action` node together — node vocabulary from + one, the connector → action → input pickers from the other. Only one of them was + written down. `GET /api/v1/automation/connectors` has served the other since + ADR-0022 by probing for the method and then re-typing its own result as `any` to + filter on `?type=`, which is a filter on a field the type system did not know + existed — one typo from silently matching nothing and answering an empty + registry, which is also what this route legitimately returns when the method is + absent, so the failure had no distinguishable symptom. + + Optional for the same reason `getActionDescriptors` is: a connector registry is a + capability of the flow-engine implementation, not a property of every automation + slot. A script-runner filling the slot has no connectors to describe, and the + route answers an empty registry rather than a 404 — the `handlerReady` posture + does not apply, since the slot is serveable and only this capability is absent. + + **`ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / + `ConnectorState` move to `@objectstack/spec/integration`**, beside the ADR-0097 + provider contract, for the reason that file already states about itself: they are + pure types, so a connector plugin — or a designer client, or the dispatcher — + speaks about registered connectors depending only on the spec, with no runtime + coupling to the engine. `ConnectorOrigin` is ADR-0097 §4 vocabulary and + `ConnectorState` is #3017 vocabulary; neither was ever engine-private in meaning, + only in location. + + Nothing is renamed and no shape changes. `@objectstack/service-automation` + imports the four back and re-exports them from its index — the same names, from + the same entry point — so every existing importer compiles unchanged. + `ConnectorState` joins that re-export, which it should have been in all along: it + is a required field of the descriptor the index has always exported. + + **The test fixture had already drifted, which is the concrete cost.** The + dispatcher's connector mock declared `{ name, label, type, actions }` and omitted + `origin` and `state` — both **required** on `ConnectorDescriptor`, and both the + fields a designer reads to tell a live declarative instance from a plugin one + (ADR-0097 §4), or a dispatchable connector from a degraded one that is listed + honestly rather than hidden (#3017). Nothing caught it, because an undeclared + return type cannot be checked against. The fixture is typed now, so it cannot + drift again, and a new test pins that `origin` / `state` / `degradedReason` + survive the hop through the route rather than only `name` and `type`. + + Verified: `@objectstack/spec` **7089 tests / 272 files** (2 new contract tests), + `@objectstack/service-automation` **457 / 41**, `@objectstack/runtime` + **218 http-dispatcher tests** (1 new), `tsc --noEmit`, `pnpm lint`, the liveness + and empty-state gates, and the three generated-artifact gates — all clean. + +- 91f4c78: fix(automation,objectql,spec): attribute `runAs:'system'` flow writes to the flow in the audit log (#4366) + + A `runAs:'system'` flow's data writes carried no attribution at all: the run + context resolved to `{ isSystem: true }` with no `userId` and no service + principal, so the audit writer recorded `user_id=null, actor=null` and the + record-history UI rendered every such row as "Unknown user" — business users + read the flow's own status mirror as data corruption. + + The `svc:*` attribution channel (ADR-0014 D2, `ExecutionContext.actor`) already + existed for exactly this class of writer; it was simply never wired end-to-end: + + - **service-automation** — `resolveRunContext` now stamps `flowName` alongside + `runAs`/`flowRunId`, and `resolveRunDataContext` labels a `runAs:'system'` + run's data context `actor: 'svc:flow:'` (fallback + `svc:flow:automation`). Attribution only — no security middleware keys on it. + - **objectql** — `buildSession` propagates `ExecutionContext.actor` onto the + hook session, closing the gap that left the audit writer's + `userId ?? session.actor` fallback unreachable from the engine path. + - **spec** — `AutomationContext.flowName` (engine-stamped, provenance) and the + hook session's optional `actor` field document the contract. + + No behavior change for user-attributed writes: `userId` still wins wherever it + is present. + +- 011b386: Reconcile the flat IO nodes' declared config against what their executors read + (#4045 — the notify / http / connector step of the declared-vs-read worklist). + + **`notify` / `http` gain executor-derived Zod contracts.** + `NotifyConfigSchema` and `HttpConfigSchema` (`automation/io-node-config.zod.ts`) + were written by reading the executors — not by transcribing the descriptors' + hand-written `configSchema` literals — and a new ledger test + (`io-node-form-zod-ledger.test.ts`) compares the two key sets bidirectionally. + Because the sides are independently written, agreement is evidence rather than + tautology: a key survives only if the form offers it AND the executor reads it. + Both nodes reconcile clean, with no deliberately-shallow ledger — their configs + are flat and fully closed. Like the control-flow config Zods, these are contract + exports: no engine path parses with them yet (that is #4045 step 3b, gated on + the #4059 warning data). + + **`connector_action`'s mis-rooted `configSchema` is retired — it broke + schema-driven authoring.** The executor reads only the declared + `FlowNodeSchema.connectorConfig` sibling block, but the descriptor published a + `configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys. A + published `configSchema` describes `node.config` by contract, and the Studio + inspector derives its property form from it — rooting every field at + `config.` and replacing the client's hand-written `connectorConfig` form + (with its connector/action pickers). So authoring a connector node against a + live backend wrote the trio where nothing reads it, and the node refused to + dispatch. The descriptor now publishes no `configSchema` (joining `wait`'s + deliberately-schemaless class), which drops the online designer back onto the + correct sibling-block form with no client change. + + **Stored flows that carry the mis-taught shape are healed at load.** A new + ADR-0087 D2 conversion, `flow-node-connector-config-lift` (protocol 17, retires + at 18), lifts `config.{connectorId,actionId,input}` onto the declared + `connectorConfig` block — including the `AutomationEngine.registerFlow` + rehydration seam. Declared keys win (the loose counterpart stays shadowed), and + a lift that cannot complete the required `connectorId`+`actionId` pair leaves + the node untouched, so a step-time refusal never becomes a load failure. + + **`connectorConfig.input` is now optional**, matching what was always true: the + executor dispatches with `input ?? {}` and the designer's keyValue editor omits + an empty map entirely — so the required `input` declared in the spec turned a + no-input connector action into a `registerFlow` parse failure nothing + downstream asked for. + +- 01e124d: Graduate `notify`'s nested `source: { object, id }` into the conversion layer (#4045). + + The `notify` executor tolerated a second spelling of its click-through target with + a bare consumer-side fallback: + + ```ts + const object = toStr(interpolate(cfg.sourceObject ?? src?.object, …)); + ``` + + Its own doc comment named `sourceObject`/`sourceId` **canonical** (they mirror the + `sys_notification.source_object`/`source_id` columns), so the nested form was an + alias tolerated by exactly the mechanism Prime Directive #12 calls debt — and the + one alias on this executor that #3796 missed when it moved `to`/`subject`/`body`/ + `url` into `flow-node-notify-config-aliases`. + + It now graduates the same way `filters` → `filter` and `object` → `objectName` + did: the conversion lifts it onto the canonical pair at load — including the + `AutomationEngine.registerFlow` rehydration seam — and the executor's fallback is + deleted, so no consumer-side dialect tolerance survives and the alias is declared, + tested and retirable on schedule (it rides the existing entry's window, retiring + at 18). + + Unlike the four renames this is a **1→2 destructuring**, which the pair mechanism + cannot express, so it is a small custom transform. It mirrors the `??` precedence + exactly: a canonical key already present wins and its nested counterpart is left + shadowed, matching how a shadowed alias is treated elsewhere. `source` is dropped + once at least one part is lifted; a `source` that is not an object, or carries + neither key, is left untouched rather than silently deleted. + + No behaviour change for authors — both spellings keep working, and a + half-specified target is still dropped rather than emitting a dead deep-link. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- 31e0be9: Flow metadata is canonicalized inside structured regions, not just at the top level (#4347). + + `registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion + table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them + walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in + its open `config` (`loop.config.body`, `parallel.config.branches[]`, + `try_catch.config.try`/`.catch`), so all three stopped at the container and metadata came + out **position-dependent**: the same node converted at the top level and did not one level + in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a + top-level edge and left a bare string on a loop-body edge. + + The reporting app shipped three sweeps whose gates never opened. Each run reported + `success: true`, queried correctly, selected exactly the right records, and then did + nothing — which is indistinguishable from "this sweep had no work to do" unless you assert + on records written. + + - **`mapFlowNodes` recurses into regions**, to any depth. Every conversion in the table now + reaches a nested node, which matters most for the two that change behaviour rather than + spelling: a `webhook` / `http_request` callout inside a loop body kept a type no executor + owns (the run failed), and a `delete_record` kept `config.filters`, leaving the canonical + `filter` the executor reads absent — the erased-condition hazard + `flow-node-crud-filter-alias` exists to prevent. Notice paths carry the region + (`flows[0].nodes[3].config.body.nodes[1].config.filter`), so the warning points at the + node to edit. + - **New `normalizeControlFlowRegions`**, called at the load seam after + `validateControlFlow`: each region is parsed through its own schema (recursively — regions + nest), so nested edges and nodes carry the same canonical shapes as top-level ones. A + region that does not parse is left untouched; rejecting one stays `validateControlFlow`'s + job, so which flows register is unchanged. + - **New `collectFlowGraphs`** yields a flow's own graph plus every nested region, each with + a scope label. Both predicate validators iterate it instead of `flow.nodes` — the engine's + `validateFlowExpressions` and `@objectstack/lint`'s author-time + `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is now + caught inside a loop body too, naming the region (`loop 'sweep' body · edge 'b1' …`). It + used to pass `objectstack validate`, pass registration, and fail at run time with the + diagnostic suppressed. + + The container executors already parse their own config at run time (`parseNodeConfig`, + #4277), so a nested predicate did evaluate correctly on current `main` — what was still + wrong is everything that reads a region _without_ re-parsing it (the Studio designer, + `getFlow`, the version history), and every conversion, none of which the executors replay. + + Also hardened, per the issue's secondary finding: `evaluateCondition`'s legacy `{var}` + template path **refuses an unresolved dotted reference** instead of comparing it as a + string. `'oppRecord.amount > 500000'` was compared `'oppRecord.amount' > '500000'` — `'o'` + against `'5'` — so it was constantly true regardless of the amount: silently wrong in the + _true_ direction, a gate that reports success while never gating. It now throws with the + source and the fix (a CEL envelope, or brace the reference if the `{var}` dialect was + meant), the same "never swallow a broken predicate" rule ADR-0032 §1c set for the CEL path. + The `try { … } catch { return false }` around that block went with it: nothing in it throws, + so it guarded nothing and would have swallowed the new refusal straight back into the silent + wrong answer. Bare-word comparisons (`'{status} == active'`) and `{var}` templates are + unchanged — only dotted references, which substitution can never leave behind, are refused. + +- ffd2ce2: `registerFlow`'s remaining validators cover structured regions (#4389). + + #4347 closed the conversion and predicate halves of "metadata behaves differently + depending on how deep it sits". Three validators were left walking `flow.nodes` only, so + the same class stayed open one layer over: an ADR-0031 container keeps a whole sub-graph + in its open `config`, and each of these checked _part_ of the flow while reporting on all + of it. + + - **`validateControlFlow` recurses.** A container nested inside another container's region + was never validated at registration — it reached run time, where `runRegion` → + `findRegionEntry` throws mid-iteration, after the enclosing loop has begun and its side + effects have landed. This cannot break a working flow: everything newly rejected was + already guaranteed to throw on execution. It also closes cycle detection over nested + regions, since region bodies are cycle-checked by `analyzeRegion` here rather than by + `detectCycles`. + - **`validateNodeTypes` covers region nodes.** Soft-fail. A node in a `loop` body is as + executable as one beside it, so the warning that exists to predict `NO_EXECUTOR` went + quiet on exactly the nodes whose run-time failure is hardest to place. + - **`validateNodeConfigKeys` covers region nodes.** Hard-fail. `visibleIf` is the typo + #4277 exists to catch, and moving the node into a region restored the silence #4277 + closed. Violations carry the region (`loop 'sweep' body · node 'w' …`). No + double-reporting from the container side: all three container descriptors declare their + region slot as a bare `nodes: { type: 'array' }` with no `items`, so the schema-lockstep + walk stops there instead of descending twice. + + **Measured before extending the two hard-fail checks**, since widening a rejecting + validator is a behaviour change rather than a bugfix: registering every flow in + `app-showcase`, `app-crm` and `app-todo` through the real `registerFlow` and re-running + each validator's own code over all 9 region graphs produced **0 new findings**. Nothing + that registers today stops registering, so the checks land at their existing severity + rather than staged through a warning window. + + `validateNodeInputSchemas` is deliberately **not** extended. It declares 0 uses across all + 159 example flow nodes, and its check compares a config value's runtime type against the + declared one — so extending it would newly fail a region node carrying a `{var}` template + string in a `number`-typed slot, which is a live authoring shape. Widening a check with a + known false-positive mode and no demonstrated reader is not worth it; the traversal gap is + noted on #4389 instead. + +- 239c3a3: fix(spec)!: the #3963 / #4052 / #4158 / #4196 / #4286 retirements land in protocol **17**, not a protocol 18 that this train cannot produce (#4350) + + Ten tombstone prescriptions told authors a key "was removed in `@objectstack/spec` **18**", + and — worse — the machine agreed with them: a whole `step18` chain step and two + `toMajor: 18` conversions were wired for a major the release train does not reach. + + **17 is what ships.** `latest` is 16.1.0 and `rc` is `17.0.0-rc.0` — 17.0.0 has never been + published. `.changeset/pre.json` records `@objectstack/spec` at initialVersion 16.1.0, and + changesets computes a pre-mode bump from the last _published_ version: 16.1.0 + `major` = + **17.0.0**, released as `17.0.0-rc.N`. `PROTOCOL_VERSION` is `'17.0.0'`, and + `protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 + either. The "18" came from counting up from the in-flight `17.0.0-rc.0` instead of from + 16.1.0. + + **The prose was the smaller half.** `composeMigrationChain(from, to = PROTOCOL_MAJOR)` + filters `m <= toMajor`, so a step keyed 18 was **unreachable**: `os migrate meta --from 16` + walked steps 11–17 and silently skipped 18. The same ceiling applies to `composeSpecChanges`, + so the generated `spec-changes.json`, `docs/protocol-upgrade-guide.md` and the `spec_changes` + MCP tool — the ADR-0087 D4 primary channel — carried **none** of these seven retirements: + `query.joins`, `query.windowFunctions` and `BatchOptions.validateOnly` appeared zero times in + the committed manifest, and the upgrade guide contained no "18" at all. Authors would have hit + the tombstones with no chain hop to run and no upgrade-guide row to read. + + What changed: + + - `step18` is folded into `step17` — its rationale, both `conversionIds` + (`stack-api-require-auth-removed`, `flow-node-wait-timeout-keys-removed`) and all six + semantic migrations move across, and `MIGRATIONS_BY_MAJOR[18]` is gone. Both conversions + become `toMajor: 17` (`migrations.test.ts` requires a conversion's `toMajor` to equal its + step's major), and `CONVERSIONS_BY_MAJOR[18]` merges into `[17]`. + - All 30 hand-written "18" references become "17": the ten tombstone prescriptions + (`query.zod.ts`, `flow.zod.ts`, `rest-server.zod.ts`, `stack.zod.ts`, `protocol.ts`), the + `query.test.ts` pin regex that was holding the wrong number in place, the internal comments, + the `liveness/query.json` + `liveness/README.md` notes, and the seven unconsumed changesets. + - The seven retirements are written into the v17 release notes and upgrade checklist, where + they had no entry at all — there is no `v18.mdx` for them to have landed in. + + No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, #4196 and #4286 + stays retired, on exactly the terms those changesets describe. What changes is that the + prescription now names the version that will actually carry it, and `os migrate meta` actually + applies the two stack conversions instead of stepping over them. + +- 667b83e: feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) + + The five descriptor-schemaless builtins (`decision` / `script` / `subflow` / + `wait` / `connector_action`) deliberately publish no `configSchema`, so their + Studio form lives only in objectui's hand-written `FLOW_NODE_CONFIG` table — + and nothing reconciled that table against the executors. `script` had drifted: + the form offered an `outputVariables` key nothing reads, two `actionType` + options (`sms` / `notification`) that fail every run, a no-op default (`code`), + and could not author the `function` / `inputs` / `outputVariable` path that + works. + + New in `@objectstack/spec/automation` — contract exports only. Unlike their + `builtin-node-config.zod.ts` siblings, which #4277 wired into execute-time + parsing, no engine path `parse()`s node config with these: `script`'s legal key + set depends on `actionType` and `decision` may branch purely on edge + predicates, so a flat parse would either reject valid shapes or check nothing. + Their enforcement is the objectui reconciliation test. + + - `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+ + `DecisionConditionSchema`) — written from the executors in + `service-automation`, the machine-readable half of the cross-repo + reconciliation objectui's `flow-node-config` test now performs. `wait` and + `connector_action` need no new schema — their contracts are the existing + `FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`). + - `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script` + executor now builds its dispatch set from the published constant, so the + designer's options, the dispatch set, and the "not a built-in action" + failure message can no longer disagree. + + Undeclared-alias graduation in the same change (Prime Directive #12, the + `map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow` + fallback is deleted, replaced by the ADR-0087 D2 conversion + `flow-node-subflow-flow-alias` — a stored `subflow` node authored with + `config.flow` is rewritten to the canonical `config.flowName` at load + (including the `AutomationEngine.registerFlow` rehydration seam). FROM + `config.flow` TO `config.flowName`; one-line fix for hand-maintained sources: + rename the key. + +- 5d21a48: feat(spec,metadata-protocol,metadata,objectql,service-automation): stored metadata replays the full conversion chain at rehydration (#3903) + + Every mechanism the platform has for evolving the metadata contract — schema + transforms, the ADR-0087 D2 conversion layer, the D3 migration chain, the + protocol-17 tombstones — operated on **authored source** only. Metadata **at + rest** (`sys_metadata` rows written by Studio or the runtime authoring APIs) + was rehydrated unparsed and unconverted, so the authored and stored contracts + silently diverged: a pre-17 row carrying `conditionalRequired` or `execute` + read as whatever each ad-hoc consumer happened to do with it. + + **New spec primitive — `applyConversionsToStoredItem(type, item, options?)`** + (exported from the package root). Wraps one stored item of a given metadata + type and replays the **full** conversion chain over it — `retiredFromLoadPath` + entries included, because retirement is an _authoring-surface_ event: the + window exists to teach a live author, and a row at rest has no author to + teach. Idempotent, never throws, never validates. + + Wired at every stored-row rehydration seam: + + - `metadata-protocol`: `loadMetaFromDb`, `getMetaItems` (active + draft + preview), `getMetaItem` (active + draft), `getMetaItemLayered`, and + `duplicatePackage` (a copy re-saves through the schema gate, so legacy + sources now duplicate successfully — and the copy is canonical). + - `metadata`: the DatabaseLoader's live-row reads (`load` / `loadMany`). + History reads stay verbatim — history records what was written. + - `objectql`: the authored-action / authored-hook direct table reads, so + runtime-authored actions stored with the removed `execute` alias dispatch + via `target` again. + - `service-automation`: `AutomationEngine.registerFlow` now passes + `includeRetired` — stored flows keep canonicalizing after their conversions + graduate out of the load window. (The generic metadata seams deliberately + skip `type: 'flow'`: flow conversions carry the open-namespace conflict + guard, which needs this engine's live executor registry.) + + **Boot hydration diagnoses instead of shrugging.** `loadMetaFromDb` now + returns `{ loaded, errors, invalid }`: each row is validated against its + type's spec schema _after_ conversion, and a genuine contract violation is + counted and warned with a stable `[metadata_spec_invalid]` marker — but still + registered, deliberately: refusing at boot would unhook live tables and make + the row unlistable and unfixable in Studio. The write path (`saveMetaItem` → 422) and the read-side `_diagnostics` envelope remain the enforcing gates; the + `SchemaRegistry.registerItem` validation hook is now documented as exactly + that diagnostic. + + **Retired accommodation.** With the chain running on every stored read path, + the rule-validator's `requiredWhen ?? conditionalRequired` fallback — kept in + #3883 with a retirement promise that had no mechanism — is deleted. If you + call `evaluateValidationRules` directly with raw legacy field definitions, + convert them first (`applyConversionsToStoredItem('object', def)`) or author + `requiredWhen`; the platform's own read paths already hand you canonical + shapes. + +- 9b702dc: The `wait` executor reads its declared contract only; the loose `config` back door graduates into the conversion layer (#4045). + + `wait` keeps its contract in `waitEventConfig` — a declared, `.describe()`-annotated + block on `FlowNodeSchema` that is in the authorable-field list, reaches the generated + reference, and is what the showcase actually authors. Its descriptor publishes no + `configSchema`, which is by design rather than the gap it first looks like. + + The executor nevertheless also read six loose `config` keys behind `wec.X ?? loose.X`, + two of them (`duration`, `signal`) spellings the spec never declared anywhere. That is + the `notify.source` shape #4050 retired: a second de-facto contract announced only by a + code comment, so an author who wrote it got a flow that worked forever and was never + steered to the declared spelling (PD #12). Not hypothetical: the showcase's own + `wait_revision` node authored it (`config: { eventType: 'signal', signalName: … }`) and + moves to the declared block here. + + - New ADR-0087 D2 conversion `flow-node-wait-event-config-lift` lifts + `config.{eventType,timerDuration,duration,timeoutMs,signalName,signal}` onto the + declared `waitEventConfig` block, in the executor's own `??` precedence — a declared + value wins and its loose counterpart is left shadowed, exactly as `renameConfigKey` + treats a shadowed alias. + - `eventType` is stamped `'timer'` whenever the lift would otherwise leave the block + without one. This is load-bearing: the loader parses the **converted** flow + (`applyConversionsToFlow` → `FlowSchema.parse`) and `waitEventConfig.eventType` is + required once the block exists — so a stored flow carrying only + `config: { duration: 'PT1M' }` would have gone from working to failing to load. + `'timer'` is the exact default the executor applied to that shape. + - The executor's six `?? loose.*` fallbacks are deleted. The surviving `?? 'timer'` is + not one: `waitEventConfig` is itself optional, and a wait node without one is a valid + timer wait. + + Verified at the real seam: the new executor tests author the legacy shape and go through + `registerFlow`, which is what applies the conversion, so they prove the graduation + end-to-end on a legacy source rather than only that the executor stopped looking. A + negative control pins the `eventType` default — deleting it from the converted output + makes `FlowSchema.parse` throw. + + Two things this deliberately does **not** change, filed as #4158 rather than fixed in + passing: `waitEventConfig.timeoutMs` is declared as a timeout guard but read as a timer + duration, and `waitEventConfig.onTimeout` has zero readers anywhere — so `wait` has no + timeout implementation at all, while the showcase authors `onTimeout: 'continue'`. + Implementing or retracting that is a behaviour change, not a contract cleanup. + +- ab16331: feat(spec)!: retire `waitEventConfig.timeoutMs` / `.onTimeout` — `wait` never had a timeout (#4158) + + Both keys described a timeout and neither delivered one, so protocol 17 removes the pair + rather than leaving a promise the runtime does not keep (PD #10). + + - **`onTimeout`** had **zero** readers. No path ever inspected it, so neither `'fail'` nor + `'continue'` ever happened — and its `.default('fail')` stamped a decision nothing made + onto every wait node. The showcase set `onTimeout: 'continue'`, which did nothing. + - **`timeoutMs`** said _"maximum wait time before timeout"_ while its only reader used it + as the timer **duration** when `timerDuration` was absent. It did something, just not + what it claimed. + + Together they declared a timeout `wait` does not have: a run resumes when its timer + elapses or its signal arrives, never on a deadline. Real timeout semantics are left + unimplemented deliberately — they should be built to a requirement, not retrofitted to + fit two keys that happened to be declared. + + `timeoutMs` **converts to `timerDuration`** rather than being dropped, because that is + what it did. It is stringified on the way: the target is `z.string()` while `timeoutMs` + was `z.number()`, and `parseIsoDuration` reads a bare numeric string as milliseconds — so + `timeoutMs: 60000` and `timerDuration: '60000'` are the same wait. Moving the number + unstringified would have produced a block that no longer parses, which a test pins. With + `timerDuration` already set it is dropped instead: the executor's `??` never looked past + the duration, so it was already dead metadata. + + Both leave the **load path** (`retiredFromLoadPath`), which is the registry's existing + split: a key retired for being _renamed_ keeps a load window, because punishing an author + for a spelling nobody warned them about is pointless; a key that **misdescribed itself** + does not, because silently absorbing it lets the author keep believing they configured a + timeout. That is why `api.requireAuth`, the tool/app/flow inert keys and RLS `priority` + all left it too. The migration chain converts stored sources mechanically; the schema + tombstones name the replacement. + + One fixture interaction worth recording: the #4045 lift fixture used + `waitEventConfig.timeoutMs` to demonstrate its fourth ledger entry, and the fixture + harness replays the whole table — so its `after` described an end state protocol 17 makes + unreachable. It now lifts `eventType` instead. The harness caught this itself. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/formula@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 8617c64986..c18ad82db3 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 c62d5be0a0..483918781f 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,210 @@ # @objectstack/service-cache +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 833eceaa66..3a64662484 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 3ab92bbd4f..681f8f3306 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,212 @@ # @objectstack/service-cluster-redis +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/service-cluster@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 659986bda9..5f62e240af 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 cb56bed688..d5464e4b42 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/service-cluster +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index a41e57aab0..cc1c3499ba 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 52feccd8f8..266de1b7ed 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,749 @@ # @objectstack/service-external-datasource +## 17.0.0-rc.1 + +### Minor Changes + +- c9d254a: feat(datasource,runtime): kernel teardown disconnects through the one datasource path — and never closes an adopted pool (#3993) + + After the #3826 connect convergence, ADR-0062 D5's "owns connect/disconnect" + was half-true: nothing disconnected the `default` (or a declared datasource's + pool) on graceful shutdown. `DriverPlugin` never had teardown, `ObjectQLPlugin` + teardown never touched drivers, and the kernel's actual teardown phase is + `destroy()` — the Plugin contract has no `stop()`, so stray `stop` methods were + never called by anything. + + The disconnect half now mirrors the connect half: + + - **`DatasourceConnectionService.disconnect(name, { asDefault })`** resolves + the default under its NATURAL name (the same #3826 rule that makes + `drivers.get('default')` impossible — the old lookup could never have found + it), and honours a new ownership discriminator recorded at connect time. + - **`disconnectAll()`** closes exactly the pools THIS service opened — + `'connected'` states only. `already-registered` drivers belong to whoever + registered them (an `onEnable` bridge, the default's idempotent replay) and + are never touched. + - **`DatasourceDriverHandle.ownership: 'factory' | 'host'`** is the + discriminator. `createPrebuiltDriverFactory` stamps its handles `'host'`: + an ADOPTED instance's pool outlives the kernel (the cloud control-plane + driver doubles as every environment kernel's proxy base; per-environment + drivers are registry-cached across kernel rebuilds), so kernel teardown — + including a cloud LRU eviction's `kernel.shutdown()` — clears the retained + verdict but NEVER closes the pool. Factory-built instances disconnect as + before there was a before. + - **`DefaultDatasourcePlugin.destroy()`** and + **`DatasourceAdminServicePlugin.destroy()`** wire the sweep at the kernel's + real teardown phase, best-effort (a failed disconnect never masks shutdown). + + A welcome side effect: a file-backed `sqlite-wasm` default with + `persist: 'on-disconnect'` now actually flushes on graceful shutdown. + + Also flips ADR-0062's status to reflect the completed convergence (#3992): + D1 is fully implemented across both repos since cloud#915; the remaining + `DriverPlugin` uses are documented named-auxiliary/escape-hatch cases, and the + degraded-boot parity guard stays with its role shifted to "the escape hatches + must not drift". + +- c3bcb42: feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826) + + ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question + open: a host whose `default` needs a driver the shared factory cannot build — + the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the + cloud control-plane driver doubles as the proxy base of every environment + kernel; per-environment drivers are cached across kernel rebuilds) — had only + two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose + connect verdict lives in `ObjectQLEngine.init()` (the second implementation + #3826 exists to retire), or fork the connect orchestration. Either re-opens the + #3741 → #3758 drift this whole line of work is about. + + Two additive pieces close it: + + - **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`** + (defaults to the shared open-core factory, byte-for-byte unchanged when + omitted). The factory only changes what `create()` returns — the policy-free + init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE` + escape hatch, and the start() replay into retained admin state are identical + either way, and the new tests pin that (an adopted instance that cannot + connect takes the exact same verdict). + - **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in + `@objectstack/service-datasource` — the "adopt an existing driver" seam the + first #3826 pass found missing, landed AS a factory so it composes into the + one connect path instead of becoming a second entry point. `create()` returns + the SAME instance every call: construction, pooling, and reuse stay host + concerns; only the verdict converges. Not for the common case — a `default` + expressible as `{ driver, config }` should stay a plain definition. + + The `@objectstack/verify` dogfood harness now boots through + `DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a + pre-built `DriverPlugin` — so the dogfood gate exercises the same declared + -default connect path `objectstack dev`/`serve` use, which is the §Risk + mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have. + The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is + still live for the boot re-verification, `DriverPlugin` escape-hatch drivers, + and the cloud compositions until they converge onto this seam. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 7bf5349: fix(service-datasource): the datasource-admin 503 names the service the route actually needs (#4225) + + `admin-routes.ts` registered nine service-backed routes behind one hard-coded 503: + + ```ts + const unavailable = (res) => + sendError( + res, + 503, + "SERVICE_UNAVAILABLE", + "The datasource-admin service is not available." + ); + ``` + + Six of those routes resolve `datasource-admin`, so the message was right. Three + resolve `external-datasource` — `GET /:name/remote-tables`, `POST /:name/test`, + `POST /:name/object-draft` — and answered with the same sentence. An operator + whose federation service was unwired was told to go look at `datasource-admin`, + which was running fine. + + The code was never the bug. `SERVICE_UNAVAILABLE` is correct for all nine: + ADR-0112's ledger asks generic conditions to reuse the standard catalog rather + than register a per-service 503 synonym, and this module documents that decision + inline. Which service is down is carried by `message`, exactly as intended — the + `message` was simply wrong on three routes. + + Rather than parameterise the 503 helper and leave the name typed out a second + time at each call site, the lookup and the message now come from one argument. + The two `adminService()` / `externalService()` resolvers collapse into a single + `resolve(res, service, method)` that answers the 503 itself, naming whatever + service it just failed to resolve: + + ```ts + const svc = resolve(res, "external-datasource", "listRemoteTables"); + if (!svc) return; + ``` + + Fixing the three messages needed only the parameter; taking the name from the + lookup is what stops a tenth route reintroducing the mismatch. The per-route + capability check is preserved — a host may wire a partial implementation, so + "the service is registered" and "this route can use it" stay separate facts. + + Wire-visible change, on those three routes only: the 503 body's `error.message` + now reads `The external-datasource service is not available.` — the same string + `packages/rest/src/external-datasource-routes.ts` already emits for its own + surface. Status and `error.code` are unchanged on all nine. + + Each of the nine 503s is now pinned to the service it names, driven through the + real `HonoHttpServer` against a context that resolves services **per name**. The + mock every existing test used answers the same object for every lookup, which is + why nothing could see this: it cannot tell the two services apart. One case + covers the operator's actual situation — `datasource-admin` wired and answering + 200s, `external-datasource` absent — including `POST /:name/test`, where the + wired admin service has a `testConnection` of its own and must not answer for the + external route. + + Pre-existing: #3843 carried every code string over verbatim and #3973 changed no + bytes on the wire. + +- 366105c: fix(service-datasource,rest): the last three uncovered datasource routes answer their registered refusal code (#4264) + + #4249 (fixed in #4263) gave the rest surface's two introspection routes a + failure contract; this closes the same gap on the three sibling routes it left + uncovered. Each had no `catch` around its service call, so a service throw was + swallowed by the adapter and surfaced as the pre-#3675 non-envelope + `500 { error: 'No response from handler' }` — no `success` flag, no + `error.message`, no code to switch on, real cause lost. + + Wire-visible changes — each route now answers `400` in the declared envelope, + under the refusal code registered (ADR-0112) for the service it dispatches to, + with the service's own message at `error.message`: + + - `GET /api/v1/datasources` (`listDatasources` throw) → + `400 DATASOURCE_ADMIN_ERROR` — matching its eight siblings in + `service-datasource/admin-routes.ts`, which already answer their catches this + way. + - `POST /api/v1/datasources/:name/external/refresh-catalog` (`refreshCatalog` + throw) and `POST /api/v1/datasources/:name/external/validate` (`validateAll` + throw) → `400 EXTERNAL_DATASOURCE_ERROR` — the same code #4249 gave the two + introspection routes one block above them. + + The issue left the code choice open (`INTERNAL_ERROR` was the alternative); + the registered per-service codes win on consistency: every other catch in both + modules — including pure reads — already answers 400 with the service-attributed + code, and `refreshCatalog`'s dominant throw class (unknown datasource, + unreachable remote, no such schema) is the one #4249 already adjudicated as a + 400 refusal on `listRemoteTables`. A 500 here would fork the failure contract + within a module — the drift #4249 removed. + + No new codes: both were registered in the error-code ledger by #4263. The + envelope-conformance suites and the `REFUSALS` pin table gain one row per + route. + +- bcf1112: fix(service-datasource,rest)!: external-datasource refusals answer their own error code (#4249) + + #4225 / #4234 fixed the 503 `message` on the three routes in + `service-datasource/admin-routes.ts` that dispatch to `external-datasource` + rather than `datasource-admin`. The identical mis-attribution survived one field + over, on the 400 path — and machine-readably: one shared `badRequest` helper + hard-coded `DATASOURCE_ADMIN_ERROR`, which the ADR-0112 ledger defines as a + refusal _from the datasource-admin service_. So a `no such schema` raised by the + external-datasource introspector was reported as datasource-admin's, and where + #4225 misled a human reading prose, this misrouted a client switching on + `error.code`. + + `EXTERNAL_DATASOURCE_ERROR` is now registered in the error-code ledger — under + `@objectstack/service-datasource` and `@objectstack/rest`, the two packages that + emit it; per the ledger's own rule the per-package rows are provenance, not + identity — and `badRequest` takes the same `ServiceName` the route passed to + `resolve` (#4234), so the code, like the 503 message, comes from the service the + route actually dispatches to. + + Wire-visible changes: + + - **The three external-datasource routes' 400 `error.code`** — + `GET /datasources/:name/remote-tables`, `POST /datasources/:name/test`, + `POST /datasources/:name/object-draft` — is now `EXTERNAL_DATASOURCE_ERROR` + (was `DATASOURCE_ADMIN_ERROR`). Status, envelope, and `error.message` are + unchanged, as is everything on the six datasource-admin routes. No consumer + branches on the old code (grepped both repos, all the ADR-0112 sweep forms). + - **The rest surface's two introspection routes now have a failure contract at + all.** `GET /datasources/:name/external/tables` and + `POST /datasources/:name/external/tables/:remote/draft` carried no + `try`/`catch`, so the very same service operations that answer 400 through + the admin surface surfaced here as the adapter's non-envelope + `500 { error: 'No response from handler' }`. They now answer + `400 EXTERNAL_DATASOURCE_ERROR` in the declared envelope — one operation, one + failure contract, on both paths. (`EXTERNAL_IMPORT_ERROR` on the import route + is unchanged: a refused import is a different act from a failed + introspection, and its name says so.) + + Why a new registered code rather than reusing one: ADR-0112's ledger asks + _generic_ conditions to reuse the standard catalog — that argument carried + #4225's 503, where `SERVICE_UNAVAILABLE` is correct for all nine routes and only + the free-text `message` named the service. A refusal specific to one service is + exactly what registered extension codes are for, and the closed `ErrorCode` + union means correcting the attribution had to be a ledger edit. Widening + `EXTERNAL_IMPORT_ERROR` to cover introspection was rejected because these are + not imports; leaving the throws uncaught was rejected because the adapter's 500 + is not the declared envelope. + + The conformance rows that pinned the drift move with it, and each surface now + pins the refusal code per route the way #4234 pinned the 503 message per route. + + Pre-existing, like #4225: #3843 carried every code string over verbatim. + +- 974c6d4: fix(datasource): a `memory` datasource is ephemeral again, and each pool gets its own store (#4083) + + The shared driver factory built `new InMemoryDriver()` for `driver: 'memory'` with + no config, so the pool inherited that driver's own `persistence: 'auto'` default — + in Node, a file adapter at the **relative, process-global** path + `.objectstack/data/memory-driver.json`. Two consequences, neither intended: + + - **It was not ephemeral.** The pool flushed its whole store into the server's + working directory (on an unref'd 2s autosave timer, and again at teardown) and + reloaded it on the next boot. That is the opposite of what the driver id + promises the operator who asks for it — `OS_DATABASE_DRIVER=memory` is + documented as _ephemeral, not real SQL_ — and it means a "throwaway" datasource + left state in the deploy directory. + - **Every memory pool in a process shared one destination.** The default path + carries no per-datasource component, so two `driver: 'memory'` datasources + loaded and saved the same file: each saw the other's tables, and the last + teardown to flush clobbered the other's rows. + + Both were visible as an intermittent test failure. The ADR-0062 D1 federated-read + acceptance seeds 2 rows into an auto-connected external memory datasource and + reads them back; it returned 2 rows on a clean checkout and 2×N on the Nth run in + the same tree — passing in CI (always run #1, always a fresh checkout) and + failing locally for anyone who ran it twice. Whether a given run leaked depended + on the autosave timer, which is what made it look flaky rather than wrong. + + - The factory now builds the memory pool with **`persistence: false` by default**. + - It also **honors the datasource's own `config`**, which was previously dropped + entirely: `initialData` and `strictMode` never reached the driver. + - When an author _does_ opt into persistence (`config.persistence`), the default + destination is **scoped to the datasource** — + `.objectstack/data/memory-.json` / `objectstack:memory-db:` — so + pools stay independent. An explicit `path`/`key`, or a custom `adapter`, is + left exactly as written. + - The dev-only sqlite step-down's last-resort in-memory driver + (`resolveSqliteDriver`, #2229) is built the same way, making its own + "not persistent" contract true. + + `InMemoryDriver`'s documented defaults are unchanged — constructing one directly + still auto-detects persistence. Only the datasource-scoped pools this factory + builds changed. + + **Migration.** A deployment relying on `driver: 'memory'` state surviving a + restart was relying on a bug, and should declare it: set + `config: { persistence: 'file' }` on the datasource (now written to a + per-datasource file), or use a real driver — `sqlite`/`sqlite-wasm` give durable + storage with real SQL. Existing `.objectstack/data/memory-driver.json` files are + no longer read; delete them. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- 0931185: fix(rest,service-settings,service-datasource)!: four more route modules emit the declared envelope, and the guard is now shared (#3843) + + #3675 and #3689 moved `service-storage` and `service-i18n` onto the declared + response envelope (`BaseResponseSchema` + `ApiErrorSchema`). Each scoped itself + to one service, and neither asked whether the same drift existed elsewhere. It + did — in four more modules, and in two of them it was the _older_ shape, the one + #3675 had already declared wrong: + + | Module | before | now | + | ------------------------------------- | -------------------------------------------------------------- | ------------- | + | `service-settings/settings-routes.ts` | nested `error`, no `success` on any of 5 bodies | full envelope | + | `service-datasource/admin-routes.ts` | `{ error: '' }`, `message` a **sibling** | full envelope | + | `rest/external-datasource-routes.ts` | `{ error: '' }` + a private `ok` | full envelope | + | `rest/package-routes.ts` | 3 of 16 bodies had `success`, 2 failures had no `error` at all | full envelope | + + ## Breaking: where to read things now + + **Success payloads move under `data`.** The keys are unchanged — only their + depth. `unwrapResponse` in `ObjectStackClient` returns `body.data` when the flag + is present, so every SDK method (`packages.list()`, `datasources.external.*`) + resolves to exactly the object it always did. Raw `fetch` callers must add one + hop: + + ``` + GET /api/v1/datasources body.datasources → body.data.datasources + GET /api/v1/datasources/drivers body.drivers → body.data.drivers + GET /api/v1/datasources/:name body.datasource → body.data.datasource + GET /api/v1/packages body.packages → body.data.packages + GET /api/v1/packages/:id body.package → body.data.package + GET /api/settings body.manifests → body.data.manifests + GET /api/settings/:ns body.manifest/.values → body.data.manifest/.values + POST /…/external/validate body.ok, body.results → body.data.ok, body.data.results + ``` + + `SettingsNamespacePayloadSchema` and friends still describe those payloads + exactly; they now describe the envelope's `data` rather than the whole body. + + **Error bodies stop being a string.** `{ error: 'datasource_admin_error', +message }` → `{ success: false, error: { code: 'datasource_admin_error', +message } }`. Read `body.error.message`, not `body.message`; read + `body.error.code`, not `body.error`. This is the asymmetry #3675 opened on: a + caller reading `body.error.message` previously got the real message from the + dispatcher and `undefined` from these routes. + + **Two failures that never said why now do.** `DELETE /api/v1/packages/:id` + answered a bare `{ success: false }` and a bare + `{ success: false, failed, cleanups }`. They are now `PACKAGE_DELETE_FAILED` and + `PACKAGE_DELETE_PARTIAL`, with the per-item `failed` / `cleanups` arrays under + `error.details`. + + **Codes follow ADR-0112.** #3841 settled the vocabulary while this was in review: + `error.code` is SCREAMING_SNAKE and `ApiErrorSchema.code` is now the closed + `ErrorCode` union, so an unregistered code fails schema parse. Generic conditions + reuse the STANDARD catalog rather than becoming registered synonyms of it, per the + ledger's own guidance: + + ``` + datasource_admin_unavailable → SERVICE_UNAVAILABLE (standard) + external_service_unavailable → SERVICE_UNAVAILABLE (standard) + not_found / PACKAGE_NOT_FOUND → RESOURCE_NOT_FOUND (standard) + PUBLISH_FIELDS_MISSING → MISSING_REQUIRED_FIELD (standard) + INTERNAL → INTERNAL_ERROR (standard) + datasource_admin_error → DATASOURCE_ADMIN_ERROR (registered) + external_import_error → EXTERNAL_IMPORT_ERROR (registered) + PUBLISH_MANIFEST_INVALID → PACKAGE_MANIFEST_INVALID (registered) + PUBLISH_FAILED → PACKAGE_PUBLISH_FAILED (registered) + PACKAGE_DELETE_PARTIAL / PACKAGE_DELETE_FAILED / SETTINGS_ACTION_FAILED (registered) + ``` + + Which service is unavailable is carried by `message`. The seven registered codes are + added to `ERROR_CODE_LEDGER` under their owning packages — including a new + `@objectstack/service-datasource` entry. + + **`POST /external/validate` keeps its `ok`.** Unlike the `{ ok: true, key }` + #3689 retired from storage — a private second word for `success` — this `ok` is a + computed verdict over the federated objects (`results.every(r => r.ok)`). The + request can succeed while the verdict is false, so the two flags are not the same + field; `ok` moves inside `data` rather than being dropped. + + Consumers were taught both shapes first, so the two repos are not coupled by + merge order: objectui's `packages` readers were already tolerant + (`payload?.data ?? payload`), and its datasource page plus the generic + `type: 'api'` action runner now unwrap the envelope and read `error.message` + (the latter previously toasted `[object Object]` for any nested error). + + ## The guard is shared now, not copied + + `scripts/check-route-envelope.mjs` + `pnpm check:route-envelope`, wired into + `lint.yml` alongside the nine sibling `check:*` guards. Its load-bearing assertion + is structural rather than per-route: **it counts the response write sites per + module.** When every body goes through the `sendOk` / `sendError` pair that count + is fixed at two and does not grow with the route list — so a _future_ route that + hand-rolls a body fails the guard. That is the coverage a driven-body test can + never give, since it can only drive the routes that existed the day it was + written. + + This existed three times already as an open-coded regex block (storage error, + storage success, i18n error). Lifting it did more than deduplicate: a per-package + scan **structurally cannot notice a module nobody thought to convert**, and going + repo-wide found two the moment it ran — neither is in #3843's hand-written survey: + + - `plugin-sharing/share-link-routes.ts` — the fifth drifting module. No body + carries `success`, and one answers `{ ok: true }`, the private second word #3689 + retired from storage. Filed as #3983 and pinned by the guard; converting it is + breaking for share-link consumers and needs its own sweep. + - `metadata/routes/hmr-routes.ts` — declared **exempt** with a reason (dev-only + SSE endpoint, not on the SDK surface), not skipped. Three states, deliberately — + conformant / ratcheted / exempt — because that is the honest classification + ADR-0049 asks for. A route module the scan finds but the table does not declare + is an **error**, never a default: applying `2 / 1 / 1` to an unknown module would + let a new one pass by coincidence. + + It also drops the regex for the TypeScript AST, fixing two real bugs the copies + had. They stripped comments with `String.replace`, whose line-comment pattern also + ate `//` inside string literals and truncated the rest of that line — response + writes included. And `.json(` does not mean "write a response": `hmr-routes.ts` + calls `c.req.json()` twice to READ a request body, which a textual count reports as + two unenveloped responses. Comments and literals are not AST tokens, and + request-vs-response is a property of the callee, so both disappear. The script + carries a `--self-test` pinning each case — the nine sibling guards have none, but + both of these bugs survived a review of the regex version. + + **The i18n ratchet, stated rather than hidden.** `i18n-service-plugin.ts` is + declared at `responses: 5, ok: 4, err: 1` with a ratchet pointing at #3973. Its + error half _is_ consolidated (#3675), but each of its four read routes builds + `{ success: true, data }` inline. Those bodies are correct — that is not envelope + drift — but an unconsolidated builder is a weaker guard: a fifth read route could + get the shape wrong and only a driven test would notice. The numbers pin today's + structure exactly (a new inline body fails) and drop to the conformant `2 / 1 / 1` + when #3973 lands. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 64360c5915..79681d1564 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 133a814cb9..d3b9ece799 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,374 @@ # @objectstack/service-i18n +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- d5c75e2: fix(spec,runtime,service-i18n): the dispatcher domains and their service contracts describe the same surface (#4127) + + #4087 retired a `/storage` bridge that called `upload(key, data, options?)` as + `upload(file, { request })` — a shape no implementation has. Sweeping the other + dispatcher domains against `packages/spec/src/contracts/*` found the mirror-image + gap in three places: the call site and the implementation agreed, and the + **contract** was the thing that had never been written down. Each one was worked + around at the call site with `typeof x.foo === 'function'` — a duck-type is what + "the contract does not cover this" looks like when nobody fixes the contract. + + Fixed at the contract, per Prime Directive #12. + + **`INotificationService` — the inbox half.** `listInbox` / `markRead` / + `markAllRead` now exist, with `InboxQuery` / `InboxNotification` / + `InboxListResult` / `MarkReadResult`. Three SDK-expressed routes + (`notifications.list` / `.markRead` / `.markAllRead`) have rested on them all + along, implemented by `service-messaging`, while this contract described only + `send`. The cost was not theoretical: the dev notification stub implements + exactly `send` and `sendBatch` **because it followed the contract**, so the one + implementation written to spec was the one the dispatcher had to duck-type past. + + They are optional, and the probe stays: an inbox needs a durable store, and a + send-only provider (SMTP, Twilio, a Slack webhook) fills the slot legitimately + without one. `handlerReady` cannot express that — the slot is serveable, one + capability of it is absent. The `/notifications` domain now takes + `INotificationService` instead of `as any`, and each write route probes its own + method rather than riding the entry `listInbox` check (they are separately + optional, so "has an inbox to read" never implied "has read-state to write"). + + **`II18nService.getFieldLabels`.** Both serving surfaces — the dispatcher's + `/i18n/labels/:object/:locale` and service-i18n's own mount — probed for it and + both documented it as "optional on `II18nService`", which was not true. It is + now. service-i18n's probe loses two casts with it (one through + `Record`, one re-declaring the signature inline). + + **`IAutomationService.getFlowRuntimeStates`** + the `FlowRuntimeState` type. + `GET /automation/_status` (and the CLI boot summary, and the + `kernel:bootstrapped` audit) already called it while the contract stopped at + `listFlows(): string[]`. The dispatcher's inline cast declared it as + `{ name, enabled, bound }` — a third copy of the shape and a narrower one than + the engine returns, dropping the `status` / `triggerType` / `object` fields that + say WHY a flow is unbound. + + Two runtime fixes fell out of the same sweep: + + - **`POST /automation/trigger/:name` now builds a real `AutomationContext`.** + It passed the raw HTTP body to `execute(name, body)`, so the + `{ recordId, objectName, params }` translation never ran and — the sharper + half — no caller identity was forwarded. A flow's default `runAs` is `'user'`, + and a `runAs:'user'` run whose trigger resolved no user has its data + operations REFUSED (#3760, fail-closed), so `client.automation.trigger()` + could not run a data-touching flow at all while `POST /:name/trigger` could. + service-automation's own comment claims "most trigger surfaces (REST action / + trigger endpoint) already resolve the full envelope"; for this endpoint it was + not true. Both routes share one context builder now. + - **The dead `automationService.trigger(...)` probe is gone.** Nothing in the + repo has ever implemented `trigger` on the automation slot and the contract + never declared it, so the branch was unreachable on every deployment and its + `execute` "fallback" was the route. Declaring `trigger?` would have blessed a + second name for `execute`; the dead branch is deleted instead. + + No migration. Every added contract member is optional, so existing + implementations stay valid; the two runtime fixes only make routes that were + failing or degraded behave like their working twins. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 2be7630e09..eb8254bbb3 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 18170f74f9..766c301bb9 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,216 @@ # @objectstack/service-job +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 74aa3f0: Jobs registered before `kernel:ready` now survive the placeholder→DbJobAdapter upgrade. + + Business plugins `start()` before the JobServicePlugin's `kernel:ready` hook, so every schedule they register lands on the placeholder IntervalJobAdapter. That placeholder silently ignores `cron` schedules, and the upgrade used `replaceService` without migrating anything — so in the default configuration a plugin's cron jobs never ran at all, while its interval timers kept running on the orphaned placeholder, invisible to `sys_job`. The upgrade now snapshots every early registration, stops the placeholder, and re-schedules them on the DbJobAdapter (whose croner-backed cron routing makes the cron entries actually fire). The IntervalJobAdapter also warns per cron registration, and the no-engine path summarizes stranded cron jobs instead of staying silent. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 40950a75b9..f446ecd264 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 55b18f4d5b..6324869835 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,180 @@ # @objectstack/service-knowledge +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 5d904d148f..16ec778013 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 e090680d0b..3c32899532 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,297 @@ # @objectstack/service-messaging +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- a8dcc37: fix(service-messaging,plugin-audit): the service that writes `sys_notification` is the one that declares it (#4154) + + `MessagingService.emit()` writes `sys_notification` on every call — it is the + pipeline's single ingress (ADR-0030 L2). But the object was contributed to the + manifest by **`AuditPlugin`**, parked there with a comment saying it would stay + "until that [ADR-0030] migration lands". The migration landed; the parking did + not move. + + That left a real deployment hole, because `AuditPlugin` is an **optional** pair + in the CLI's plugin table. Install messaging without audit and nothing registers + the object, so the engine has no schema to issue DDL from and every `notify()` + fails with `no such table: sys_notification`. AuditPlugin never wrote the row + itself — it deliberately routes through this service's `emit()` ingress + (`getMessaging()` in `audit-writers.ts`), and its own exclusion list already + annotates the object as "messaging-owned (ADR-0030)". + + The contribution now lives with the writer, matching how every other + service-owned platform object is handled in this repo — `service-job` imports + `SysJob`/`SysJobRun`, `service-queue` imports `SysJobQueue`, `rest` imports + `SysImportJob`. Ownership of the _definition_ is unchanged: the object stays in + `@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`, because + owning a definition and contributing it to a running kernel are different + things. It is also added to the service's `provisionSystemTables`, so the table + is created with the rest of the pipeline it heads rather than lazily on the + first write. + + Found while migrating `notifications.hono.integration.test.ts` to in-memory + SQLite in #4065: that suite had to register the object itself to boot, which was + the deployment bug in miniature. The workaround is deleted in this change — the + suite now boots messaging alone and passes, which is the proof the product + declares what it writes. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index b83c8f6c8f..3156bd1398 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,8 +1,8 @@ { "name": "@objectstack/service-messaging", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "license": "Apache-2.0", - "description": "Messaging Service for ObjectStack \u2014 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.", + "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", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index b060e87641..f0281f4bf1 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,181 @@ # @objectstack/service-package +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/metadata-core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index b3c6221383..d30d0c643b 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 618e19e5f2..eb49360d20 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,260 @@ # @objectstack/service-queue +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 58a03d2: fix(objectql,spec,metadata-protocol,service-queue): engine option bags are now a closed contract — unknown keys throw instead of silently doing nothing (#4371 option 2) + + The engine declares `Engine*OptionsSchema` but never parses it at runtime, so + any option key outside the contract — a typo (`orderby`), a retired key + (`cursor`), a wire-protocol leftover (`object`, `count`), a key that only + works on other methods (`tenantId` on `count`) — rode along and was silently + ignored. All six methods now reject non-null unknown keys, naming the legal + set; retired keys (`cursor`/`distinct`) quote their #4286 tombstone; `null` + stays a withdrawal. + + Per-method legal keys = the method's schema keys plus the documented extras: + `searchFields` (now declared on `EngineQueryOptionsSchema` — it was read by + the engine's `$search` expansion and sent by the protocol layer all along), + `onFieldsDropped` on `update` (contract-declared write observability), and + the driver pass-through keys (`transaction`, `tenantId`, `tenantIds`, + `timezone`, `bypassTenantAudit`, `preserveAudit`) on `find`/`findOne`/ + `update`/`delete` — the methods whose bag actually reaches driver options. + `count`/`aggregate` never forward their bag, so pass-through keys there are + rejected rather than accepted-and-ignored. A drift pin holds the sets equal + to the schemas. + + Also closed in the same sweep: + + - A bag-level `object` key used to OVERRIDE the resolved object on the query + AST (`{ object, ...query }` spread order), splitting `ast.object` from the + table actually queried. The AST now keeps the resolved name; a direct call + passing `object` is rejected, and the protocol layer refuses a POST-body + `object` that contradicts the route (400 `QUERY_OBJECT_MISMATCH`) instead + of picking a winner. + - `findData` no longer leaks protocol-layer vocabulary (`object`, `count`, + `joins`, `windowFunctions`, `cursor`, `distinct`, non-aggregate `having`) + onto the engine bag. + - Nested expand ASTs (`expand: { rel: { sort } }`) reject the four wire-only + spellings exactly like the top-level bag (#4371 option 1 did the top level). + - The engine's OData-spelling reads (`$search`/`$searchFields`) are gone — + the protocol normalizes to the bare keys; a direct call passing them now + throws instead of half-working on one method. + - `DbQueueAdapter.purge`/`purgeFailed` passed `{ id }` — a key the engine + never read, so purge deleted NOTHING (each delete threw into a warn-level + catch) and purgeFailed always threw. Both now pass `{ where: { id } }`; + the test fake's `delete` no longer accepts the signature the real engine + rejects. + + Migration for direct engine callers (wire/HTTP callers are unaffected): pass + only the keys your method's `Engine*OptionsSchema` declares (plus the extras + above). Anything else previously did nothing — delete it, or move it to the + layer that owns it. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 362acf1918..dbce45a4ff 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ac40905976..32e5c8350a 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,212 @@ # @objectstack/service-realtime +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index c78d7af88c..ab98207952 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 a1aa770621..3ba2e76012 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,537 @@ # @objectstack/service-settings +## 17.0.0-rc.1 + +### Minor Changes + +- f1f40b4: refactor!: settings error bodies stop hanging undeclared keys beside `code`/`message` (#4224) + + Four `/api/settings/*` error branches spread ad-hoc context as SIBLINGS of `code` + and `message` inside `error`. `ApiErrorSchema` declares `code`, `message`, + `category?`, `httpStatus?`, `details?`, `requestId?` — and none of `namespace`, + `key`, `reason`, `fields`. The bodies passed every gate anyway: `ApiErrorSchema` + is a plain `z.object`, so unknown keys were **stripped** rather than rejected, + and `envelopeViolations` inspects only the body's top level. They were conformant + _by stripping_, not by declaration. The same module already used the declared + slot correctly one branch over (`SETTINGS_ACTION_FAILED` → `error.details`), so + this is one file speaking two dialects, not a missing capability. + + **Wire change — FROM → TO.** In every case the values are unchanged; only their + position moves, into the `details` slot the contract declares: + + | Code | HTTP | FROM | TO | + | --------------------- | ---- | ---------------------------------------------- | ---------------------------------------------------------------------- | + | `SETTINGS_FORBIDDEN` | 403 | `error.namespace` | `error.details.namespace` | + | `UNKNOWN_KEY` | 400 | `error.namespace`, `error.key` | `error.details.namespace`, `error.details.key` | + | `SETTINGS_LOCKED` | 409 | `error.namespace`, `error.key`, `error.reason` | `error.details.namespace`, `error.details.key`, `error.details.reason` | + | `SETTINGS_VALIDATION` | 400 | `error.namespace`, `error.fields` | `error.details.namespace`, `error.details.fields` | + + **One-line fix for a consumer:** read `error.details.` where you read + `error.`, or `error.details?. ?? error.` if you support servers on + both sides of the change. The console's own fix (objectui#3078) is the tolerant + form. + + **`SETTINGS_VALIDATION.fields` also changes shape**, because `fields` is the name + ADR-0114 (#3977) closed for `FieldError[]` and keeping a map under it would leave + one spelling meaning two shapes: + + - **FROM** `{ [key]: message }` — a `Record`, the constraint named + only in the prose of the message. + - **TO** `FieldError[]` — `{ field, code, message, label, constraint? }`, where + `code` is a member of the closed field-level catalog: `required` for an empty + required specifier, `invalid_format` for a value that misses its declared + `pattern` (which travels as `constraint.pattern`). + + A consumer that rendered the map's values reads `f.message` per entry instead; + one that wants to branch on _why_ a value was rejected can now read `f.code` + rather than substring-matching English. objectui's `extractFieldErrors` already + reads `details.fields`, so settings validation failures become renderable + per-field there with no further change. + + **The exported `SettingsValidationError.fields` changes with it** — same + `Record` → `FieldError[]` mapping — since the route only relays + what the service throws, and the constraint kind is knowable at the throw site + and nowhere after it. + + `sendError`'s last parameter is tightened from `extra?: Record` + to `ApiError`'s own optional fields, and its `code` from `string` to the closed + ADR-0112 `ErrorCode` union. That is what keeps this fixed: an undeclared sibling + is now a compile error at the call site rather than a key that quietly evaporates + at the schema boundary. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- 0931185: fix(rest,service-settings,service-datasource)!: four more route modules emit the declared envelope, and the guard is now shared (#3843) + + #3675 and #3689 moved `service-storage` and `service-i18n` onto the declared + response envelope (`BaseResponseSchema` + `ApiErrorSchema`). Each scoped itself + to one service, and neither asked whether the same drift existed elsewhere. It + did — in four more modules, and in two of them it was the _older_ shape, the one + #3675 had already declared wrong: + + | Module | before | now | + | ------------------------------------- | -------------------------------------------------------------- | ------------- | + | `service-settings/settings-routes.ts` | nested `error`, no `success` on any of 5 bodies | full envelope | + | `service-datasource/admin-routes.ts` | `{ error: '' }`, `message` a **sibling** | full envelope | + | `rest/external-datasource-routes.ts` | `{ error: '' }` + a private `ok` | full envelope | + | `rest/package-routes.ts` | 3 of 16 bodies had `success`, 2 failures had no `error` at all | full envelope | + + ## Breaking: where to read things now + + **Success payloads move under `data`.** The keys are unchanged — only their + depth. `unwrapResponse` in `ObjectStackClient` returns `body.data` when the flag + is present, so every SDK method (`packages.list()`, `datasources.external.*`) + resolves to exactly the object it always did. Raw `fetch` callers must add one + hop: + + ``` + GET /api/v1/datasources body.datasources → body.data.datasources + GET /api/v1/datasources/drivers body.drivers → body.data.drivers + GET /api/v1/datasources/:name body.datasource → body.data.datasource + GET /api/v1/packages body.packages → body.data.packages + GET /api/v1/packages/:id body.package → body.data.package + GET /api/settings body.manifests → body.data.manifests + GET /api/settings/:ns body.manifest/.values → body.data.manifest/.values + POST /…/external/validate body.ok, body.results → body.data.ok, body.data.results + ``` + + `SettingsNamespacePayloadSchema` and friends still describe those payloads + exactly; they now describe the envelope's `data` rather than the whole body. + + **Error bodies stop being a string.** `{ error: 'datasource_admin_error', +message }` → `{ success: false, error: { code: 'datasource_admin_error', +message } }`. Read `body.error.message`, not `body.message`; read + `body.error.code`, not `body.error`. This is the asymmetry #3675 opened on: a + caller reading `body.error.message` previously got the real message from the + dispatcher and `undefined` from these routes. + + **Two failures that never said why now do.** `DELETE /api/v1/packages/:id` + answered a bare `{ success: false }` and a bare + `{ success: false, failed, cleanups }`. They are now `PACKAGE_DELETE_FAILED` and + `PACKAGE_DELETE_PARTIAL`, with the per-item `failed` / `cleanups` arrays under + `error.details`. + + **Codes follow ADR-0112.** #3841 settled the vocabulary while this was in review: + `error.code` is SCREAMING_SNAKE and `ApiErrorSchema.code` is now the closed + `ErrorCode` union, so an unregistered code fails schema parse. Generic conditions + reuse the STANDARD catalog rather than becoming registered synonyms of it, per the + ledger's own guidance: + + ``` + datasource_admin_unavailable → SERVICE_UNAVAILABLE (standard) + external_service_unavailable → SERVICE_UNAVAILABLE (standard) + not_found / PACKAGE_NOT_FOUND → RESOURCE_NOT_FOUND (standard) + PUBLISH_FIELDS_MISSING → MISSING_REQUIRED_FIELD (standard) + INTERNAL → INTERNAL_ERROR (standard) + datasource_admin_error → DATASOURCE_ADMIN_ERROR (registered) + external_import_error → EXTERNAL_IMPORT_ERROR (registered) + PUBLISH_MANIFEST_INVALID → PACKAGE_MANIFEST_INVALID (registered) + PUBLISH_FAILED → PACKAGE_PUBLISH_FAILED (registered) + PACKAGE_DELETE_PARTIAL / PACKAGE_DELETE_FAILED / SETTINGS_ACTION_FAILED (registered) + ``` + + Which service is unavailable is carried by `message`. The seven registered codes are + added to `ERROR_CODE_LEDGER` under their owning packages — including a new + `@objectstack/service-datasource` entry. + + **`POST /external/validate` keeps its `ok`.** Unlike the `{ ok: true, key }` + #3689 retired from storage — a private second word for `success` — this `ok` is a + computed verdict over the federated objects (`results.every(r => r.ok)`). The + request can succeed while the verdict is false, so the two flags are not the same + field; `ok` moves inside `data` rather than being dropped. + + Consumers were taught both shapes first, so the two repos are not coupled by + merge order: objectui's `packages` readers were already tolerant + (`payload?.data ?? payload`), and its datasource page plus the generic + `type: 'api'` action runner now unwrap the envelope and read `error.message` + (the latter previously toasted `[object Object]` for any nested error). + + ## The guard is shared now, not copied + + `scripts/check-route-envelope.mjs` + `pnpm check:route-envelope`, wired into + `lint.yml` alongside the nine sibling `check:*` guards. Its load-bearing assertion + is structural rather than per-route: **it counts the response write sites per + module.** When every body goes through the `sendOk` / `sendError` pair that count + is fixed at two and does not grow with the route list — so a _future_ route that + hand-rolls a body fails the guard. That is the coverage a driven-body test can + never give, since it can only drive the routes that existed the day it was + written. + + This existed three times already as an open-coded regex block (storage error, + storage success, i18n error). Lifting it did more than deduplicate: a per-package + scan **structurally cannot notice a module nobody thought to convert**, and going + repo-wide found two the moment it ran — neither is in #3843's hand-written survey: + + - `plugin-sharing/share-link-routes.ts` — the fifth drifting module. No body + carries `success`, and one answers `{ ok: true }`, the private second word #3689 + retired from storage. Filed as #3983 and pinned by the guard; converting it is + breaking for share-link consumers and needs its own sweep. + - `metadata/routes/hmr-routes.ts` — declared **exempt** with a reason (dev-only + SSE endpoint, not on the SDK surface), not skipped. Three states, deliberately — + conformant / ratcheted / exempt — because that is the honest classification + ADR-0049 asks for. A route module the scan finds but the table does not declare + is an **error**, never a default: applying `2 / 1 / 1` to an unknown module would + let a new one pass by coincidence. + + It also drops the regex for the TypeScript AST, fixing two real bugs the copies + had. They stripped comments with `String.replace`, whose line-comment pattern also + ate `//` inside string literals and truncated the rest of that line — response + writes included. And `.json(` does not mean "write a response": `hmr-routes.ts` + calls `c.req.json()` twice to READ a request body, which a textual count reports as + two unenveloped responses. Comments and literals are not AST tokens, and + request-vs-response is a property of the callee, so both disappear. The script + carries a `--self-test` pinning each case — the nine sibling guards have none, but + both of these bugs survived a review of the regex version. + + **The i18n ratchet, stated rather than hidden.** `i18n-service-plugin.ts` is + declared at `responses: 5, ok: 4, err: 1` with a ratchet pointing at #3973. Its + error half _is_ consolidated (#3675), but each of its four read routes builds + `{ success: true, data }` inline. Those bodies are correct — that is not envelope + drift — but an unconsolidated builder is a weaker guard: a fifth read route could + get the shape wrong and only a driven test would notice. The numbers pin today's + structure exactly (a new inline body fails) and drop to the conformant `2 / 1 / 1` + when #3973 lands. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- 64f8cbe: feat(platform-objects,service-settings,verify): `sys_secret` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the settings service (#4270) + + The environment's encrypted-secret store (`sys_secret`, ADR-0066 D2/④) was + registered by `@objectstack/service-settings`, but it has three producer + classes and only one of them is settings: the settings service's encrypted + specifiers, the ObjectQL engine's own `secret`-field encryption + (`encryptSecretFields`/`resolveSecret` — the generic write path of ANY + business object carrying a `Field.secret()`), and the datasource credential + binder. Unlike the `sys_migration` precedent (#4243), the failure posture is + fail-CLOSED: on a kernel composed without settings, every insert/update of an + object with a secret field threw — with an error message that told the + operator to "Ensure the platform-objects (sys_secret) are registered", naming + a package that did not register it. + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the store exists with the + platform, independent of which optional services are composed, and the + engine's fail-closed error message is true. Definition ownership is unchanged + (`sys_secret` stays in `@objectstack/platform-objects` and in + `PLATFORM_OBJECTS_BY_PACKAGE`); the settings service remains a producer and + consumer through its `sys_secret`-backed secret store. + + Consequences: + + - `@objectstack/service-settings` no longer contributes `sys_secret` to the + manifest (`settingsObjects` is now `[SysSetting, SysSettingAudit]`). An + embedder composing `SettingsServicePlugin` on a hand-built kernel that + relied on it for the `sys_secret` table must compose + `PlatformObjectsPlugin` (the plugin every supported assembly path already + includes). The move REPLACES the registration — nothing registers the + object twice. + - `@objectstack/verify`'s boot harness now composes `PlatformObjectsPlugin`, + mirroring `os serve`'s auto-inject — which also means harness kernels now + carry the `sys_migration` ledger + fresh-datastore attestation (#4243) the + served assembly always had. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index c5e42646a7..7f6efb6db5 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 3abfd38342..51526f10ae 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,244 @@ # @objectstack/service-sms +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 3b2d6d4272..456a87cdae 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 7ca22779d7..68c6efce34 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,523 @@ # @objectstack/service-storage +## 17.0.0-rc.1 + +### Minor Changes + +- b1863a5: feat(storage): released field files enter collection on deployments that verified their file migration — ADR-0104 D3 wave 2 PR-5b (#3459) + + The gated, final step of the file-as-reference sequence. On a deployment whose + `adr-0104-file-references` flag is verified (`os migrate files-to-references +--apply`, #3617), releasing a field file's ownership — clearing the field, or + deleting the owning record — now also tombstones the file + (`status='deleted'` + `deleted_at`), which starts the `sys_file` lifecycle's + declared 30-day grace window and, at its end, hands the row to the reap sweep. + Re-referencing the id inside the window revives it, exactly like re-attaching + an attachment. + + **The two halves ship together, deliberately.** The same change extends the + reap guard's sweep-time re-verify beyond `sys_attachment` join rows to the + ownership columns: a tombstoned file whose `ref_*` columns name a current + owner (re-claimed in the window, or a release/claim race) is un-tombstoned and + vetoed. Tombstoning released files without that re-verify would have turned + every release into a _guaranteed_ byte delete — the guard's old check consults + a table that is always empty for field files. This pairing was the standing + hard constraint on #3459, locked by regression tests on both halves. + + **Nothing changes for a deployment that has not migrated.** Release keeps + clearing the ownership columns only, and released files are retained forever. + Every way of not knowing — no flag row, an unreadable table, an engine that + cannot be asked — reads as "not verified": the gate fails closed, toward + retention. And the guard re-reads the flag _fresh_ at sweep time (not the + release path's memoized read), so a later failing migration run — a database + that has drifted — closes the gate for already-written tombstones too, without + a restart. Attachments-scope collection is unchanged and needs no flag. + + The irreversible moment is therefore per deployment: day 30 after _that_ + deployment verified its migration and released a file — never the upgrade + itself. + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363) + + #4363 made a single paged read a partition of its result set. It could not make + a _walk_ one: seven background scans paged with a growing `offset` while writing + to the very rows they were reading, and an offset counts into a set those writes + are changing. Rows slide past the cursor and are never visited. + + That is not a slow page in any of these — it is a wrong answer wearing the shape + of a clean run: + + - **`rebuildApproverIndex`** built its desired state by walking + `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then + **deleted** every index row that state did not explain. A skipped request + meant an approver silently dropped from someone's queue. (The loop beside it + ordered by `created_at` — not unique, so its pages were never a partition + either.) + - **`verifyFileReferences`** decides which files nothing references. A record it + never visits is reported as an unreferenced file. + - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite + each row they read, so their own writes were shifting the set out from under + the cursor. Records were left unconverted and unsearchable by a run that + reported success. + - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and + it opens a migration gate on that evidence. + + All of them now go through `keysetWalk` (`@objectstack/types`): order by a + unique key, and seek past the last one instead of counting from the start. A + row's key does not move when the row is updated, and cannot be shifted when + another is deleted, so the walk is stable under exactly the mutation these + functions perform. It is also O(n) rather than O(n²/page) — measured on + Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek. + + One deliberate non-conversion: the REST **export** stream keeps its offset. It + honors a caller-chosen sort, and a keyset walk would have to re-order the export + by `id` to seek — changing what the user asked for to fix a cost. Its pages are + already a partition since #4363; only the depth cost remains. + + `keysetWalk` merges the cursor with `$and` rather than spreading it into the + caller's filter, so a walk whose own `where` constrains the key column + (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently + overwritten. When a `max` cap is set it reads one row beyond the cap to tell + "the cap stopped us" from "the source ended exactly there" — without that, a + walk that read everything still reports `truncated`, and a caller acting on it + goes looking for rows that were never withheld. + + The storage suites' fake engines now **throw** on an `offset` instead of serving + one, so the conversion is pinned rather than merely passing. + +- be7360c: chore(plugins,services): declare `providesServices` on the 20 remaining init-time service providers (ADR-0116 follow-up, #4131) + + ADR-0116 gave the kernel a declared ordering contract, but only + `ObjectQLPlugin` and `MetadataPlugin` had declared what their `init()` + registers. The pre-Phase-1 ordering check can only _name a provider_ for + services someone declared, so its coverage was two plugins wide. + + An audit of every plugin's `init()` body (brace-matched, comments stripped, + each call classified by whether it sits inside a `try`/`if`) found 20 plugins + that register a service on every path without declaring it. All 20 now + declare `providesServices`. Purely additive: no ordering changes, no new + failure modes — a `providesServices` entry only lets the kernel say _who_ + provides a service when it reports a misordering, and enriches the Phase-1 + `getService` miss diagnostic. + + Three needed a closer read before declaring, because they register the same + service from several branches (`cache`, `queue`, `job`): each early-return + branch plus the fallback registers it, so every path does — the declaration + is honest. ADR-0116's rule that a _conditionally_ registered service must + never be declared is unchanged and was applied throughout. + + The same audit found 12 plugins that hard-resolve a service during `init()` + (11 of them `manifest`) without declaring `requiresServices`. None is a live + exposure — every one already declares a hard `dependencies` entry on the + provider, so the kernel orders them correctly today. Those are tracked + separately: with a hard dependency in place, `requiresServices` mostly + restates what the kernel already enforces, and its real value is on + _soft_-dependency consumers, of which `AppPlugin` is currently the only one. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +- efcd68c: **The storage adapter stops being rebuilt and re-pointed on every boot, and the + "files may be unreachable" warning stops firing at a healthy server (#4096).** + + Every `os dev` / `os serve` boot printed: + + ``` + WARN StorageServicePlugin: storage adapter swapped (LocalStorageAdapter → + LocalStorageAdapter). Existing files were NOT migrated and may be unreachable + through the new adapter. + ``` + + The warning was telling the truth. `serve` constructed the plugin with + `{ driver: 'local', root }` — and `StorageServicePluginOptions` declares + neither key. Both were dropped silently, so the plugin applied its own + `./storage` default, `OS_STORAGE_ROOT` changed nothing, and uploads landed in a + directory nobody named. The `storage` settings namespace then corrected the root + on its first read (its manifest default is `./.objectstack/data/uploads`), + genuinely moving the backing store — every boot, forever. + + Three fixes, because there were three defects: + + - **`serve` now passes options the plugin reads** — `{ adapter: 'local', +local: { rootDir } }`. `OS_STORAGE_ROOT` takes effect, and local uploads land + under `.objectstack/data/uploads` from the first byte instead of `./storage`. + Extracted as `resolveStorageCapabilityArg` so the option SHAPE is pinned by + tests: a mismatch like this type-checks fine and does nothing at runtime. + - **A swap is skipped when nothing changed.** The plugin records what the + running adapter points at and compares resolved configurations, instead of + rebuilding whenever the settings namespace held any value at all — which is + every boot once that namespace has persisted its own defaults. + - **The warning now means what it says.** It fires when the BACKING STORE moved + (kind change, different root, different bucket/region/endpoint), not merely + when the adapter object was replaced. A credential rotation swaps the adapter + so the new key takes effect and logs at info: same bucket, nothing stranded. + A swap from a caller that resolved no target still warns — ignorance must not + silence it. + + Path spellings are normalised, so the platform writing the same default two ways + (`./.objectstack/data/uploads` in the settings manifest, + `.objectstack/data/uploads` in the CLI) is no longer read as a migration between + a directory and itself. + + Verified on `examples/app-todo`: the boot-diagnostics block went from four + warnings to three, with the storage line gone and `./storage` no longer created. + 19 unit cases cover the target resolver and the swap/warn split (including the + refusals), 4 plugin-level cases pin what a boot does and says, and 7 pin the CLI + option shape. + + `config.storage` authored with the `driver`/`root` dialect is still forwarded + verbatim and still not read by the plugin — the same mismatch one layer up. + Correcting it means deciding whether the plugin accepts that dialect or the + config schema is wrong, so it is filed rather than papered over with a lenient + alias here (AGENTS.md Prime Directive #12). + +- 68dea0b: feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243) + + The deployment-level data-migration flag ledger (`sys_migration`, #3617) was + registered by `@objectstack/service-storage` as its first consumer. That was + deliberate while the file migration was the only consumer, but the ledger now + gates storage-independent behaviour too — `os migrate value-shapes` (#4235) + and the fresh-datastore attestation (#4215) — and a non-file migration had to + boot the whole storage plugin just so the kernel carried the table. Any kernel + assembled without storage silently had no ledger at all, which read exactly + like "migration not run" (both answer false) while actually meaning "ledger + not installed". + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the ledger exists with the + platform, independent of which optional services are composed. The + fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger + bookkeeping, and its old home justified itself as "the service that registers + `sys_migration`". Definition ownership is unchanged (`sys_migration` stays in + `@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the + flag helpers and readers are untouched. + + Consequences: + + - `@objectstack/service-storage` no longer contributes `sys_migration` to the + manifest and no longer performs the fresh-datastore attestation. An embedder + composing `StorageServicePlugin` on a hand-built kernel that relied on it + for the ledger must compose `PlatformObjectsPlugin` (the plugin every + supported assembly path already includes). + - The CLI's `buildDataMigrationPlugins()` no longer boots storage for every + gated migration — it registers `PlatformObjectsPlugin` always, and settings + - storage only for `os migrate files-to-references` (`{ storage: true }`), + the one migration that actually reconciles against the storage adapter. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [2a37694] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/observability@17.0.0-rc.1 + - @objectstack/types@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index ffd7b27d46..ad1815991d 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 645ef16fa4..444d2afbcb 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,5892 @@ # @objectstack/spec +## 17.0.0-rc.1 + +### Major Changes + +- 2d3e255: feat!: ADR-0113 — `required` is a write contract; the column constraint becomes the explicit `storage.notNull` + + `field.required` bound three meanings to one knob (write check, `NOT NULL` DDL, + drift expectation), so tightening any invariant on a deployed object was a + destructive migration blocked by the very legacy nulls that motivated it — the + reason `criteria_json`'s mandatory-in-substance contract lived in three + imperative guards instead of one declaration. + + Split, with the **non-regression invariant** as the unifying rule — _a write + may not take a record from compliant to violating; a pre-existing violation + does not block writes that leave it in place_: + + - `required: true` = the write contract, uniformly on new and deployed objects: + insert must provide; **an update PATCHing `null` into a required field is now + rejected** (it silently passed before); omitted fields never block, so legacy + null rows rest. The column stays nullable. + - `storage: { notNull: true }` = the explicit physical constraint, owning the + DDL (`sql-driver` `createColumn`) and the destructive drift ceremony. + Orthogonal to `required` — all four combinations are legitimate, including + the engine-populated column (`storage.notNull` without `required`). + - `requiredWhen` inherits the same invariant: flipping the condition true + without providing the field is rejected (the write _creates_ the violation); + a row violating since before the rule tightened no longer locks out + unrelated edits (#3929's objection, cured). `storage.notNull` × + `requiredWhen` rejects at parse (`FieldSchema.superRefine`). + - **Pre-17 sources keep their exact meaning** via the migration-chain-only + `field-required-notnull-explicit` conversion: `os migrate meta` stamps + `storage.notNull` onto every previously-required field — writing down what + the old text already meant. The loader never infers semantics from the + physical column. + - Drift compares nullability against `storage.notNull`; a column stricter than + its declaration is `needs_confirm` (never auto-applied — dev auto-reconcile + no longer silently strips a stray `NOT NULL`), and silent when the field is + write-gated by `required`. + +- 5dc4d02: feat(spec)!: `EnhancedApiError.fieldErrors` → `fields`, tombstoned (ADR-0114 D4, #3977) + + Completes ADR-0114 D4, which the field-level catalog PR (#4035) decided but left + unexecuted because retiring an authorable key needs its own sequence. + + **FROM → TO:** `EnhancedApiError.fieldErrors` → `EnhancedApiError.fields`. The array + and its element shape are unchanged — only the property name. + + The wire has always carried `fields`: the validators, import coercion, + `validation-failure.ts`, `@objectstack/client` and the console's field-error + extractor all say `fields`. `fieldErrors` was declared and emitted by nobody, so + anyone reading `error.fieldErrors` was reading a field no server sent — ADR-0078's + silently-inert declaration, sitting on the error envelope. + + **The old key is tombstoned, not deleted.** `EnhancedApiErrorSchema` is not + `.strict()`, so a plain removal would let a producer still writing `fieldErrors` + parse clean and lose the per-field detail — a validation failure that mentions no + field. Writing it now fails with the rename prescription instead + (`retiredKey()`, ADR-0104). + + **Migration:** read `error.fields`. There is nothing to run: this is a response + envelope, so no stack, example or template carries the key and `os migrate meta` + has no source to rewrite. The change is recorded as a semantic chain entry + (`enhanced-api-error-field-errors-renamed`) with its reason and acceptance + criterion, which is what reaches the generated upgrade guide and the + `spec_changes` MCP tool. + +- 9b6fe7c: fix(spec,runtime)!: `AnalyticsQueryRequest` is the bare `AnalyticsQuery`; the dispatcher validates `/analytics` bodies at the entry (#3878) + + **Spec.** `AnalyticsQueryRequestSchema` used to describe a + `{ cube, query: {...}, format }` ENVELOPE — the dialect of the retired degraded + analytics shim (#3891), which the real engine never understood: an envelope + body inferred a column-less cube and died as an SQL syntax error + (`SELECT FROM …`) instead of a shape error. The schema now describes what the + engine and every real caller actually use — the **bare `AnalyticsQuery`**: + + ``` + FROM { "cube": "orders", "query": { "measures": ["count"] }, "format": "json" } + TO { "cube": "orders", "measures": ["count"], "dimensions": [...], "where": {...} } + ``` + + `cube` + `measures` are required at the top level; `dimensions` / `where` / + `timeDimensions` / `order` / `limit` / `offset` / `timezone` sit beside them. + The schema is `.strict()`; `query` and `format` are tombstoned (`retiredKey`) + so both `tsc` and the parse answer with this exact migration. `format` was + never implemented (every response is the JSON envelope) — for CSV/XLSX use the + export surface. The removal is registered as two step-17 semantic migrations + (`analytics-query-request-envelope-retired`, + `analytics-query-request-format-retired`) — it is an HTTP-wire change with no + stored metadata to rewrite. + + **Runtime.** `POST /api/v1/analytics/query` and `/analytics/sql` now validate + the body against that schema AT THE ENTRY and answer + **400 `VALIDATION_FAILED`** with per-field details — including the envelope + prescription above, and a bespoke hint that `filters` is not a contract field + (the filter field is `where`, the same canonical FilterCondition `find()` + takes). Previously a malformed body reached the engine and failed as a 500 SQL + syntax error, or had its off-contract filter key silently ignored. A valid + body is forwarded to the analytics service byte-identical (validation only — + parsing would inject the schema's `timezone: 'UTC'` default and override + org-timezone resolution). An uninstalled analytics capability still answers + 404 before any body inspection (#3891). + +- 8c711fb: feat(spec)!: tombstone the seven dead AppSchema authoring keys (#4001 app step, PR A) + + The 2026-06 AppSchema liveness audit verdicted seven authorable keys DEAD — + never read by any consumer in framework or objectui. Authoring them shipped + config the author believed was in effect; `sharing`/`embed` were the + dangerous case (a declared public-access surface no route enforced, + ADR-0049 class). All seven are now `retiredKey()` tombstones: `tsc`-level + `never` for typed authors, a parse-time prescription for everyone else — + NOT a silent strip, because `AppSchema` is not yet `.strict()`. + + **Removed keys and their prescriptions (FROM → TO):** + + - `App.version` → an app is versioned by its owning package: `manifest.version`. + - `App.aria` → declare `aria` on the component/widget that renders the DOM node. + - `App.objects` / `App.apis` → the self-described "config file convenience"; + objects/apis belong to the stack (`defineStack({ objects, apis })`) — the + chatbot derives an app's object list from its NAV ITEMS, never from these. + - `App.sharing` / `App.embed` → public access is granted per FORM VIEW + (`FormView.sharing`, the public-data-collection surface); no public-app or + iframe route ever read the app-level blocks. + - `App.mobileNavigation` → fully unimplemented (even `packages/mobile` + ignored it); returns if/when a real mobile navigation ships. + + Deleting a key is behavior-preserving by construction — none ever had a + runtime effect. `os migrate meta --from <16 or lower>` rewrites your source + (the `app-dead-authoring-keys-removed` conversion, ADR-0087), or delete the + keys by hand. + + This clears the ADR-0049 precondition for PR B (AppSchema + navigation-union + `.strict()`): strictness should guard the real contract, not dead keys. + +- 09e4547: feat(spec)!: reject unknown keys across the app shell and navigation tree (#4001 app step, PR B) + + Closes the last high-traffic authorable surface in the unknown-key strictness + ratchet (flow + permission #4071, RLS / sharing / position #4099, approval + #4119, App dead-key tombstones #4142). The app shell is the densest + hand-authored surface on the platform — a navigation tree is where an author + or AI is most likely to write a key from memory — so a silent strip here was + the most probable instance of the #3405 trap. + + - **`AppSchema`** and its sub-schemas (`AppBrandingSchema`, + `NavigationAreaSchema`, `AppContextSelectorSchema` + its `optionsSource` / + `filter` blocks, `NavigationContributionSchema`) are `.strict()`. + - **`NavigationItemSchema` becomes a DISCRIMINATED union on `type`.** This is + what makes strict readable: a plain union of strict members answers one + unknown key with an `invalid_union` aggregate naming all nine branches, + while discriminating on `type` first yields a single `unrecognized_keys` + issue against the branch the author actually wrote — at an exact path + through nested `children` — and a mistyped `type` gets its own "Invalid + discriminator value". Each variant carries its own suggestion pool, so a + `url` item is never told about `dashboardName`. + - **Still OPEN by design:** `PageNavItem.params`, `ComponentNavItem.params` + and `ActionNavItem.actionDef.params` — per-target payloads owned by the + page / component / action, not by the nav item. + + **A real defect the gate caught, in the platform's own app:** `ACCOUNT_APP` + declared `defaultOpen` on three navigation groups. That was never a schema + key — `expanded` is — so all three shipped COLLAPSED while their author + believed they opened by default. Fixed at the producer (contract-first) and + `defaultOpen` / `open` / `collapsed` / `isOpen` now alias to `expanded`. + + **Migration.** Any key now rejected was previously stripped and had no + runtime effect. The error carries the fix; mappings include + `menu`/`sidebar`/`tabs`/`items` → `navigation`, `title` → `label`, + `permissions` → `requiredPermissions`, `sort`/`position` → `order`, + `defaultOpen` → `expanded`, `args` → `params` (actionDef), `primary` → + `primaryColor`, `url` → `endpoint` (options source), plus wrong-layer + pointers: `pages`/`views`/`flows` are not App fields, and a payload named on + the wrong variant points at the `type` that owns it. + + The `visibleWhen` → `visible` alias is the load-bearing one: ADR-0089 made + `visibleWhen` canonical on view/page schemas, so an author who learned it + there would silently lose a nav entry's visibility gate — a capability gate + failing open, the worst shape of the silent-strip bug. + +- 12a19a8: refactor(spec)!: the #3896 close-out sweep — fourteen inert authoring keys leave the surface + + The enforce-or-remove worklist across the remaining metadata types, each key + tombstoned at its schema with the prescription (`retiredKey`) and stripped by + a protocol-17 conversion (`os migrate meta` rewrites sources): + + - **action** `shortcut` / `bulkEnabled` — no keydown path ever dispatched a + shortcut; the multi-select toolbar reads the view's `bulkActions`. + - **flow** `active` / `template`, node `outputSchema`, errorHandling + `fallbackNodeId` — `active: false` never stopped a flow (`status` is the + enforced lifecycle; the default even read as disabled while the engine + treated unset as enabled); faults route via per-node fault edges. + - **view** list `responsive` / `performance`, form `defaultSort` / `aria` — + no renderer read any of them. List `aria`/`data` stay live, and **form + `data` survived the sweep**: the removal attempt broke the build — + `defineForm` writes `data.provider='schema'` onto every metadata form — + which re-verified the entry; its ledger verdict is corrected instead. + - **dashboard** `aria` / `performance`, widget `performance` (+ the orphaned + `PerformanceConfigSchema`) — no renderer applied them; virtual scrolling is + the live top-level `virtualScroll`. + - **agent** `knowledge` (+ `AIKnowledgeSchema`) — declaring sources/indexes + never scoped retrieval: `search_knowledge` takes `sourceIds` from the LLM's + tool-call arguments. The protocol-17 `topics`→`sources` rename is absorbed + into the removal pre-release. + - **skill** `triggerPhrases` — phrases were never matched; activation is + `triggerConditions` ∩ the agent's `skills[]` allowlist. + + Docs-shaped annotation fields (`hook.label`/`description`, `flow.description`) + are deliberately KEPT and so noted in the ledger — they document intent for + the next reader and are exempt from enforce-or-remove. The stale report + `aria`/`performance` ledger entries (schema already clean) are deleted as + hygiene. + +- 217e2e6: refactor(spec)!: remove the Dev Mode Plugin Protocol family — a declared dev protocol nothing implemented, whose stub vocabulary described the design ADR-0115 retired (#4149) + + `DevPluginConfigSchema`, `DevServiceOverrideSchema`, `DevFixtureConfigSchema`, + `DevToolsConfigSchema` and `DevPluginPreset` (`@objectstack/spec/kernel`, + dev-plugin.zod) are removed outright. The family declared a full dev-mode + configuration protocol — presets (minimal/standard/full), fixture loading, a + dev-tools dashboard on port 4400, per-service `mock`/`memory`/`stub`/ + `passthrough` strategies, simulated latency — and none of it ever existed: + + - **Zero consumers.** `@objectstack/plugin-dev` reads its own + `DevPluginOptions` interface and never imported these schemas; no other + package did either. The only references were the generated artifacts and the + family's own shape test. + - **No load path parsed it.** `stack.devPlugins` takes + `ManifestSchema | string` (plugin manifests/names), not this config — so no + authored source can carry these keys, which is why there is deliberately NO + `retiredKey()` tombstone and NO ADR-0087 conversion: a prescription nobody + can receive is noise, and there is no source for `os migrate meta` to + rewrite (the `plugin-runtime.zod.ts` DynamicLoadingConfig precedent, #3950). + - **Its vocabulary contradicted the platform.** `strategy: 'stub'` taught the + fill-the-slot-with-a-fake design that ADR-0115 retired; keeping the schema + meant the spec recommending what the runtime forbids. + + FROM → TO: if you imported any of the five types from + `@objectstack/spec/kernel`, delete the import — there is nothing to migrate to, + because nothing ever consumed the values. To configure local development, use + `DevPluginOptions` from `@objectstack/plugin-dev` (port, seedAdminUser, + authSecret, per-part `services` toggles, extraPlugins, stack). + + The retirement kit: baselines dropped deliberately (`json-schema.manifest.json` + minus the five `kernel/Dev*` entries; `authorable-surface.json` minus the 22 + `kernel/Dev*` lines — nothing can author them, so no `[RETIRED]` markers); + `api-surface.json` regenerated (the five exports leave the public surface); + generated reference doc removed by `gen:docs`; v17 release notes' dead-clusters + table extended. No liveness-ledger entries existed (the ledger tracks metadata + types; this was never one). + + No runtime behaviour changes — that impossibility is the reason for the removal. + +- 3c628ce: feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963) + + `api.requireAuth: false` let a deployment open its ENTIRE data plane with one + config key. It is removed. Auth is a kernel concern, not a deployment posture: + anonymous callers are denied on every HTTP surface that reaches object data, + unconditionally. + + Every surface that legitimately serves a session-less caller already derives its + own narrow authorization from a DECLARATION, so none of them needed the global + switch: + + - control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069 + remediation) — the auth-gate allowlist; + - public form submission — `publicFormGrant` (ADR-0056 Option A); + - share links — the capability token, validated then read as SYSTEM; + - a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995); + - MCP — an OAuth token or API key. + + **Breaking changes.** + + - `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both + `RestApiConfigSchema` and the stack `api` block, so authoring it now fails with + a fix-it message rather than being silently stripped (the ADR-0104 / #3733 + quiet-failure this whole line of work has been closing). `os migrate meta` + drops it via the protocol-17 conversion `stack-api-require-auth-removed`. + - `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth` + input; it denies any anonymous, non-system caller outside the control-plane + allowlist. + - A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a + data API (`objectstack serve`, plugin-dev), instead of getting an explicit + fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data + API. There is no anonymous-data carve-out any more — publishing a public + surface is done by declaration (see above). + + **Migration.** Delete `api.requireAuth` from the stack config (or run + `os migrate meta`). If you were serving data publicly with `requireAuth: false`, + replace it with the declaration that fits: a public form view, a share link, or + `book.audience: 'public'`. If you have an auth-less stack that intentionally + served data, it must now mount auth or stop serving the data API. + +- a648e96: fix(spec,service-automation)!: `errorHandling.maxRetries` has one default, and `strategy: 'retry'` states its count (#4247) + + `flow.errorHandling.maxRetries` was declared twice, with different values: + + - **spec** — `FlowSchema` (`automation/flow.zod.ts`): `.default(0)` + - **engine** — `retryExecution` (`service-automation/src/engine.ts`): + `errorHandling.maxRetries ?? 3` + + `??` fires only on `undefined`, so the winner was decided by the ROUTE a flow + took into the engine, not by what its author wrote: + + | Path | `errorHandling.maxRetries` | Retries | + | :---------------------------------------------- | :------------------------- | ------: | + | parsed by `FlowSchema` (`.default(0)` fills it) | `0` | **0** | + | object built by hand and fed to the engine | `undefined` | **3** | + + One authored intent — "I didn't write a count" — two behaviors. The neighbouring + `retryDelayMs ?? 1000` / `backoffMultiplier ?? 1` agreed with their `.default()`s; + only `maxRetries` disagreed, which reads as a schema default changed from 3 to 0 + without the engine following, not as a deliberate two-track design. + + **The engine keeps no defaults of its own.** `retryExecution` now takes the + parsed `NonNullable` and destructures all five + knobs — no `??`. This is safe because `AutomationEngine.flows` only ever holds + `FlowSchema.parse` output (`registerFlow` parses; the version-history rollback + re-seats an already-parsed snapshot), and it is what keeps a second set of + defaults from growing back: a knob the spec stops defaulting becomes a compile + error rather than a silent engine-side guess. Per Prime Directive #12 the spec + is the one contract; a consumer-side fallback is a second de-facto one. + + **BREAKING — `strategy: 'retry'` now requires `maxRetries` >= 1.** With the + engine's copy gone, an unstated count is unambiguously `0`, and `'retry'` with 0 + attempts runs the flow once and stops — i.e. `strategy: 'fail'` wearing another + label, a declared capability the runtime does not deliver (Prime Directive #10 + corollary). Rather than pick 0 or 3 on the author's behalf, `FlowSchema` refuses + the combination in both spellings (omitted → defaulted 0, and an explicit 0), + with the prescription in the message. A retry re-runs the **whole flow from the + start** — records created again, callouts fired again — which is not a number to + guess for someone. + + FROM → TO: + + - `errorHandling: { strategy: 'retry' }` → `errorHandling: { strategy: 'retry', maxRetries: 3 }` + (or `strategy: 'fail'` if no retry was intended — that is what it did). + - `errorHandling: { strategy: 'retry', maxRetries: 0 }` → same choice, spelled out. + + Unaffected: `maxRetries: 0` under `strategy: 'fail'` / `'continue'` (neither + reads it, and a fully spelled-out block stays legal), flows with no + `errorHandling` at all, and every flow that already states a count — including + the `try_catch` node's own `config.retry`, which is a separate per-region policy + (`control-flow.zod.ts`) and is unchanged. + +- 7777e8f: fix(spec)!: retire the never-built typed-event system; the lifecycle registry now lists the events that actually fire (#4212 follow-up) + + The lifecycle-event surface promised a typed-event system that was never + built, in three layers. `kernel/plugin-lifecycle-events.zod.ts` shipped ten + payload schemas (`PluginRegisteredEvent`, `PluginErrorEvent`, + `HookTriggeredEvent`, `KernelReadyEvent`, …) and a 21-name + `PluginLifecycleEventType` enum — zero consumers for every export, and the + enum was wrong in both directions: 17 names nothing fires, 10 real events + missing. `contracts/plugin-lifecycle-events.ts` declared the same 17 dead + names in `IPluginLifecycleEvents` next to 5 real ones, plus an + `ITypedEventEmitter` interface nothing implements. All of it read as a + promise; anyone who coded against it (hooking `plugin:started`, awaiting + `plugin:error`) registered a handler that could never fire, with no error + saying so — the same silent-drop shape as the #4212 lifecycle-hook family. + + Removed, with zero consumers verified repo-wide: + + - `kernel/plugin-lifecycle-events.zod.ts` and every export: `EventPhase`, + `PluginEventBase`, `PluginRegisteredEvent`, `PluginLifecyclePhaseEvent`, + `PluginErrorEvent`, `ServiceRegisteredEvent`, `ServiceUnregisteredEvent`, + `HookRegisteredEvent`, `HookTriggeredEvent`, `KernelEventBase`, + `KernelReadyEvent`, `KernelShutdownEvent`, `PluginLifecycleEventType` + (schemas and inferred types). + - `ITypedEventEmitter` from `contracts/plugin-lifecycle-events.ts`. + - The 17 never-fired names from `IPluginLifecycleEvents`. + + `IPluginLifecycleEvents` is now the registry of the **14 events with a real + emitter** — `kernel:{ready,bootstrapped,listening,shutdown}`, `app:seeded`, + `metadata:reloaded` (payload `metadata` now optional, matching the documented + contract), `external.schema.drift`, `ai:routes`, `auth:configure`, and the + `{service}:ready` convention family (`mcp`, `automation`, `analytics`, + `external-datasource`, `datasource-admin`) — each payload as observed at its + fire site. A new `LifecycleEventName` union types + `PluginContext.hook`/`trigger` in `@objectstack/core` as + `LifecycleEventName | (string & {})`: known names autocomplete, custom + cross-plugin names stay legal, existing callers compile unchanged. A pinning + test asserts two-way equality between the interface keys and the fire-site + inventory. + + FROM → TO: + + - `PluginLifecycleEventType` → `LifecycleEventName` (the union of names that + fire). There is no runtime enum; the bus is open by design. + - Event payload schemas (`KernelReadyEvent`, `PluginErrorEvent`, …) → the + payload tuples on `IPluginLifecycleEvents`. No wire format existed or + exists; payloads are in-process arguments. + - `ITypedEventEmitter` → `PluginContext.hook`/`trigger` (the emitter that + actually exists). + - Handlers for the 17 dead names → delete them; they never ran. For plugin + phase observation use the boot report (ADR-0084); for per-plugin errors the + kernel throws/logs at the failing phase. + + Plain deletion rather than `retiredKey()` tombstones, per the #4233 + precedent: these keys were never authorable — they described runtime event + payload records no config author can write, so the silent-strip class the + authorable-surface ratchet guards against is vacuous. Its baseline entries + and the `json-schema.manifest.json` keys are dropped deliberately in this PR. + No ADR-0087 conversion: no stack metadata names these types; there is nothing + for `os migrate meta` to rewrite. + +- 7309c81: fix(driver-memory,spec): persistence is opt-in again — `new InMemoryDriver()` is pure in-memory (#4065) + + `InMemoryDriverConfig.persistence` defaulted to `'auto'`, and in Node.js `'auto'` + means **file**. So a bare `new InMemoryDriver()` — the shape every caller in this + repo used — silently wrote `.objectstack/data/memory-driver.json` into the process + CWD and reloaded it on the next boot. The default is now `false`. + + **This restores the accepted design rather than replacing it.** #815, the issue + that introduced the persistence capability, specified it as opt-in in requirement + \#1 — "默认情况下不启用持久化(纯内存,行为不变)" — and listed + `new InMemoryDriver()` under "纯内存" in its own config examples. The `'auto'` + default was a drift from that spec. + + What let the drift survive is worth naming, because it is not "there was no + test". `MemoryConfigSchema` _did_ pin the default, and asserted `'auto'`; the + driver honoured `'auto'`; so spec and implementation agreed, and the pair looked + verified. What nothing checked was whether the value they agreed on was the one + #815 accepted. The driver's own `persistence.test.ts` could not have caught it + either — every case there passes `persistence` explicitly, so the omitted-value + path was untested on the implementation side. Both sides are now covered: three + behavioural tests in `persistence.test.ts` (no CWD write, no cross-instance row + carry-over, opt-in still persists) and the flipped schema assertion. + + **The symptom this fixes.** `packages/runtime/src/datasource-autoconnect.test.ts` + seeds two rows with fixed ids and asserts the exact set. Run 1 passed and wrote + the rows to disk; run 2 loaded them back, appended two more, and failed with four + rows; run N had 2N. CI never saw it — every job is a fresh clone, so every CI run + is run 1 — but `pnpm test` twice in one working tree could only ever go green + once. The persisted file's `created_at` values, one pair per run, were the proof. + + (#4083 fixed that particular suite from the factory side, and its regression + test is kept as-is. The blast radius was wider than one suite, though: **every** + bare `new InMemoryDriver()` inherited the default, so any code path constructing + one directly wrote to its working directory. Unit tests should not have write + side effects on the CWD at all.) + + **Migrating.** Callers that want durability now ask for it: + + ```ts + new InMemoryDriver(); // pure in-memory (new default) + new InMemoryDriver({ persistence: "file" }); // Node.js, durable across restarts + new InMemoryDriver({ persistence: "local" }); // browser, durable across reloads + new InMemoryDriver({ persistence: "auto" }); // previous default behaviour + ``` + + The `'auto'` / `'file'` / `'local'` / custom-adapter paths are unchanged; only + the value used when `persistence` is omitted moved. + + **Relationship to #4083.** That issue fixed the same hazard one consumer at a + time, and landed first: `createDefaultDatasourceDriverFactory` now passes + `persistence: false` for a declared `{ driver: 'memory' }` datasource and scopes + an opted-in destination _per datasource_, and the dev sqlite step-down's + last-resort rung passes `false` too. Both are kept exactly as #4083 wrote them. + This change closes the half they deliberately left open — a directly-constructed + `new InMemoryDriver()` — which is the path that still wrote into the working + directory of whatever process happened to build one. + + The two are complementary, not redundant. #4083's per-datasource scoping is + still the only thing that expands `'auto'`/`'file'`/`'local'` into a destination + carrying the datasource name, so two pools that DO opt in never alias one file; + its explicit `false` becomes belt-and-braces, which is the right posture for a + path that must never persist. + + `DevPlugin`'s driver is now explicitly `persistence: false`, matching the cache, + queue, job, i18n, storage and search stubs it ships beside — it was the one piece + of that stack that quietly outlived the process. + + **One claim trimmed, no behaviour attached.** The class docstring called this a + "production-ready implementation of the ObjectStack Driver Protocol". It stores + no constraints at all — `create()` is a `table.push()` and `syncSchema()` only + allocates an array — so there is no primary key, uniqueness, `NOT NULL`, foreign + key or column typing, and `bulkCreate` lands duplicate ids where a SQL driver + raises a violation (the second finding in #4065). The docstring now says so, and + points test authors at in-memory SQLite. Per Prime Directive #10 the fix for + `declared ≠ enforced` is to implement it, trim the claim, or file it; with this + driver moving to maintenance-only the claim is what goes. + +- 40e420f: refactor(spec)!: retire the plugin lifecycle-hook family the kernel never implemented (#4212) + + `PluginLifecycleSchema` declared `onInstall` / `onEnable` / `onDisable` / + `onUninstall` / `onUpgrade`, each with confident TSDoc ("Called when plugin is + installed", …). **The kernel calls none of them, and never has.** Its plugin + contract is `init` / `start` / `destroy` (`packages/core/src/types.ts`): + `kernel.use()` validates and stores, `bootstrap()` runs `init` then `start`, + shutdown runs `destroy` in reverse order. There is no install phase and no + first-time-only path. Repo-wide, the schema's only importer was its own test — + and the docs built on it sent authors at a dead seam (`registerMetadataTypeSchema` + told plugins to register custom metadata types "from their `onInstall` hook"; + a plugin that obeyed registered nothing, with no error saying so). + + Removed, with zero consumers verified across `objectstack`, `objectui` and + `cloud`: + + - `PluginLifecycleSchema` + `PluginLifecycleHooks`, and the five hook members + on `PluginSchema` (now a plain descriptor object). + - `UpgradeContextSchema` + `UpgradeContext` — existed solely to serve + `onUpgrade`. + - The `@objectstack/spec/system` `./types` module: the `ObjectStackPlugin` + interface (the same three hooks as a TS contract, referenced by no file in + any repo) and its companions `PluginContext` (interface duplicate), + `PluginLogger`, `ObjectQLClient`, `IKernel`, `ObjectOSKernel` — seeded by + the aspirational spec in issue #2 and never consumed since. + + FROM → TO: + + - `onInstall` → there is no install-time code hook; installation is a + package-registry state transition (`registry.installPackage()` → + `sys_packages`). Install-shaped setup (registering services, schemas, + metadata types) belongs in the plugin's **`init(ctx)`**. + - `onEnable` (kernel plugin) → `init(ctx)` / `start(ctx)`. The _app-bundle_ + `onEnable` module export is a different, real contract and is unchanged — + `AppPlugin` invokes it at boot (`STACK_RUNTIME_MEMBERS`). + - `onDisable` / `onUninstall` → `destroy()` for runtime teardown; package + uninstall is a registry transition. + - `onUpgrade` / `UpgradeContext` → package upgrades apply metadata migrations + (ADR-0087); no plugin code runs. + + One-line fix: replace the hook object with a class implementing + `Plugin` — `name` + `async init(ctx)` (+ optional `start`/`destroy`). + + Plain deletion rather than `retiredKey()` tombstones because nothing parses + plugin objects through these schemas (`stack.zod` carries `plugins` as + `z.array(z.unknown())`) — a prescription nobody can receive is noise (the + `plugin-runtime.zod.ts` precedent). Per that precedent, the key-vanish guard's + baseline entries (`kernel/UpgradeContext:*` in `authorable-surface.json`) are + dropped deliberately in this PR. A pleasant side effect: with the function + members gone, `PluginSchema` became JSON-representable and now publishes a + `kernel/Plugin` JSON schema for the first time. No ADR-0087 conversion: these are function + members on runtime objects, not authorable stack metadata; there is no source + file for `os migrate meta` to rewrite. The kernel docs + (`protocol/kernel/{index,lifecycle,plugin-spec}.mdx`) that documented the + fictional lifecycle — including a manifest `lifecycle:` file-map ManifestSchema + never declared — now document the real contract. + +- 5b47ab5: refactor(data)!: the QueryAST request surface stops declaring what no executor runs — `joins` and `windowFunctions` removed, six search flags and `aggregations[].filter` marked experimental, and the liveness ledger now governs the query surface (#4286) + + #4196 removed one declared-but-inert member from `FieldNode`. Applying the same + method to the rest of the request surface (#4286) found 12 more members of + `QueryAST` that no executor runs — `packages/objectql`'s `engine.ts` contains + zero reads of any of them on the query path. This change dispositions the + mechanical tiers and closes the gate that let the class stay invisible. + + **Removed (tombstoned): `query.joins` and `query.windowFunctions`.** + + - `joins` — no engine or driver ever read it; a query carrying it silently ran + as a single-table query. Related-record retrieval already has a live + spelling: `expand`. The orphaned `JoinNode` / `JoinNodeInput` / + `JoinNodeSchema` / `JoinType` / `JoinStrategy` exports are deleted with the + key (`data/JoinNode`, `data/JoinType`, `data/JoinStrategy` leave the + published JSON schemas). + - `windowFunctions` — `find()` never applied it, so every OVER clause it + declared was silently dropped. The one live door is the SQL driver's own + `findWithWindowFunctions(object, query)` (driver-level, not on the + `IDataDriver` contract), and its input is a flat driver shape the spec + vocabulary never matched — `WindowFunctionNodeSchema` declared `field` / + `over` / `frame` members that door never read. The `WindowFunction` / + `WindowSpec` / `WindowFunctionNode` exports are deleted with the key. + + **FROM → TO** + + | Was | Now | + | :------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------- | + | `joins: [{ type: 'inner', object: 'customer', on: … }]` | `expand: { customer_id: { object: 'customer', fields: ['name'] } }` | + | `joins` for one related column | `fields: ['customer_id.name']` (dotted path) | + | `windowFunctions: [{ function: 'rank', … }]` in a query | `aggregations` + `groupBy`, or rankings in report/dashboard metadata | + | OVER-clause SQL from an embedder | `sqlDriver.findWithWindowFunctions(object, { windowFunctions: [{ function, alias, partitionBy?, orderBy? }] })` | + + The one-line fix: **delete the key**. Both are `retiredKey()` tombstones on the + non-strict `BaseQuerySchema`, so authoring either fails `tsc` (input type + `never`) and a query still carrying one — even as an empty array — fails to + parse with the prescription itself. `QueryAST` is a request shape, never stored + in stack metadata, so there is no `os migrate meta` step: the removals are + registered as protocol-17 **semantic** migrations (`query-joins-retired`, + `query-window-functions-retired`), the #4196 precedent. + + Compat note for the REST boundary: both names remain **reserved** list-query + parameters while the tombstones live (`retiredKey()` keeps a key in + `keyof QueryAST`, which feeds `RESERVED_LIST_QUERY_PARAMS`), so nothing changes + for objects with fields named `joins`/`windowFunctions` — the un-reservation + happens when the tombstones age out, and is called out in + `metadata-protocol`'s `QUERY_AST_KEYS` comment for whoever does it. + + **Marked `[EXPERIMENTAL — not enforced]` (no wire or compat impact):** + `search.fuzzy` / `operator` / `boost` / `minScore` / `language` / `highlight` + (the ADR-0061 expansion reads only `query` + `fields`) and + `AggregationNode.filter` (a SQL `FILTER (WHERE …)` affordance neither the SQL + builders nor the in-memory fallback applies). Authoring one is now a + declaration, not a silent no-op. + + **Deliberately NOT dispositioned here** (they want a maintainer call, #4286 + steps 3–4): `having` (the strongest enforce candidate — `engine.aggregate()` + currently rebuilds the driver AST without it), and `cursor` / `distinct` + (shipped SDK producers `QueryBuilder.cursor()` / `.distinct()`; `distinct` is + mis-wired — its only observable effect is suppressing the REST list count). + All three are recorded `dead` with evidence in the new ledger. + + **The gate:** `QuerySchema` joins the liveness ledger through the gate's + `SPEC_ONLY_SCHEMAS` override (the `webhook` precedent) as governed type + `query` — the first governance of what _callers_ write into a query rather + than what authors write into metadata files. `packages/spec/liveness/query.json` + classifies all 27 walked members (15 live with evidence, 7 experimental via + describe markers, 5 dead), so the next declared-but-inert request member fails + CI instead of needing a person to notice it. + + `@objectstack/plugin-security` (patch): the FLS predicate guard's + `windowFunctions` walk is pruned — the clause no longer exists to leak through. + The `having` and `aggregations[].filter` walks stay, deliberately: those + members remain declared, and the guard being ready is what makes enforcing + them later safe. + +- b09d8d9: refactor(data)!: `query.cursor` is removed — no driver ever implemented keyset pagination (#4286 step 4) + + `cursor` promised keyset pagination and nothing served it: the key was accepted + and ignored, so every page came back identical — a caller looping "until + `hasMore` is false" never terminated. It was Tier A of the #4286 inventory: a + shipped public producer (`QueryBuilder.cursor()`) minting a key no executor + read. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------- | :------------------------------------------------------------------------- | + | `cursor: { created_at: last.created_at }` | `where: { created_at: { $gt: last.created_at } }` + the matching `orderBy` | + | `QueryBuilder.cursor({...})` | `.where({ created_at: { $gt: ... } }).orderBy('created_at')` | + + The one-line fix: **delete the key and seek with `where` on your sort key** — + every driver already executes that, with canonicalised temporal comparands. + + Mechanics: `retiredKey()` tombstones on both declaration sites + (`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared + prescription), so authoring the key fails `tsc` and a query still carrying it + fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as + the protocol-17 semantic migration `query-cursor-retired` (request surface — + nothing stored to rewrite). The caller-built `Record` shape + would not survive a real keyset design anyway: a first-class cursor, if ever + built, will be a response-minted opaque token (the pattern the + metadata-revision / flow-run / notification list endpoints already use — those + `cursor` params are unrelated and unchanged). + +- b09d8d9: refactor(data)!: `query.distinct` is removed, and with it the mis-wired REST count suppression (#4286 step 4) + + `distinct` promised `SELECT DISTINCT` and no driver ever rendered it — but it + was **mis-wired rather than merely dead** (#4286 finding 2, the harsher + ADR-0078 class): its only observable effect platform-wide was that the REST + list path treated a distinct query as _not countable_, silently degrading + `total`/`hasMore` to a page-local estimate while still returning duplicate + rows. A caller — or a self-verifying agent — saw the response change and + concluded the flag worked. It had a shipped public producer + (`QueryBuilder.distinct()`). + + **FROM → TO** + + | Was | Now | + | :--------------------------------------- | :-------------------------------------------------------------------------------- | + | `distinct: true` for unique combinations | `groupBy: ['category']` | + | `distinct: true` + count | `aggregations: [{ function: 'count_distinct', field: 'category', alias: '...' }]` | + | one column's distinct values | the SQL/memory drivers' `distinct(object, field)` door (driver-level) | + + The one-line fix: **delete the key**; deduplicate with `groupBy` / + `count_distinct`. + + Mechanics: `retiredKey()` tombstones on both declaration sites + (`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared + prescription); `QueryBuilder.distinct()` is deleted; registered as the + protocol-17 semantic migration `query-distinct-retired`. **Observable REST + change (`@objectstack/metadata-protocol`):** the count-suppression branch is + deleted — a list request that used to carry `distinct` now gets a real + `total`/`hasMore` again (that restoration is the point, not a side effect). + The per-aggregation `distinct` flag (`AggregationNode.distinct`) is a + different, live member and is untouched. + +- 8675db6: refactor(data)!: a select-list entry is a field name — the nested-select object form is removed (#4196) + + `FieldNode` declared two forms for one entry of `QueryAST['fields']`: + + ```ts + type FieldNode = + | string // "name" + | { field: string; fields?: FieldNode[]; alias?: string }; // nested select + ``` + + The object form was **declared-but-inert**. Nothing produced it, and nothing + read `.fields` or `.alias` — every consumer on the path treats the list as + `string[]`: `objectql`'s formula projection and its two known-field filters, + `driver-sql`'s `select()`, `driver-memory`'s `projectFields`. `driver-mongodb` + keyed its projection with the entry itself, so an object entry asked for a + column literally named `"[object Object]"`, and the REST ingress stringified + each entry before comparing it to the field map, so the same entry came back as + `400 INVALID_FIELD: Unknown field '[object Object]'` — a rejection naming + something the caller never wrote. An author who wrote + `fields: [{ field: 'owner', fields: ['name'] }]` got it accepted by validation + and then dropped or mangled, depending on the driver (ADR-0078 silently-inert + declaration; ADR-0049 enforce-or-remove). + + The capability the object form described is already served, by a different key. + Removing the second spelling rather than lowering it into the first is Prime + Directive #12: one capability, one contract. + + **FROM → TO** + + | Was | Now | + | :---------------------------------------------------------------- | :--------------------------------------------------------------- | + | `fields: [{ field: 'owner', fields: ['name'] }]` | `expand: { owner: { object: 'user', fields: ['name'] } }` | + | `fields: [{ field: 'owner' }]` | `fields: ['owner']` | + | `fields: [{ field: 'owner', fields: ['name'] }]`, one column only | `fields: ['owner.name']` (dotted path) | + | `fields: [{ field: 'total', alias: 't' }]` | `aggregations` / `windowFunctions` — they carry the live `alias` | + + The one-line fix: **a `fields[]` entry is a string.** Move nested selection to + `expand`, which the engine resolves through batch `$in` queries (default max + depth 3). + + There is no `os migrate meta` step, and deliberately so: `QueryAST` is a request + shape, never stored in stack metadata, so the chain has no source to rewrite. It + is registered as an ADR-0087 D3 **semantic** migration + (`query-field-node-object-form-retired`) on the protocol-17 step instead — the + `EnhancedApiError.fieldErrors` / `BatchOptions.validateOnly` precedent. Callers + move their own select lists, and both channels tell them how: + + - **The parse.** `FieldNodeSchema` narrows to `z.string()` with an error map that + answers an object entry with the prescription above, not "expected string, + received object". `z.input` becomes `string`, so `tsc` fails at the authoring + site first. + - **The ingress.** `assertProjectionFieldsExist` judges the entry's _shape_ + before consulting the object's field map — it is wrong about the shape, not + about this object, and a registry-less host would otherwise pass it to a driver + that cannot read it. The 400 now names the retired form instead of the field + `"[object Object]"`. + + No runtime behaviour changes for anything that ever worked; the defensive + unwrapping the drivers had grown against a shape nothing sends goes with it. + +- 62f8017: refactor(spec)!: remove the plugin sandboxing / integrity / approval config that never existed (#3896 follow-up) + + `DynamicLoadingConfigSchema`, `PluginDiscoveryConfigSchema` and + `PluginDiscoverySourceSchema` declared a plugin security control set — + `defaultSandbox`, `requireIntegrity`, `allowedSources`, and discovery's + `requireApproval` ("require admin approval before loading discovered plugins"). + + **None of it was ever wired to anything.** The three schemas were an island: not + composed into any parent schema, not read by any runtime, referenced only by + their own round-trip tests. They were nonetheless published into `json-schema/` + and the authorable key surface, where an author — very often an AI (ADR-0033) — + would read them as capabilities this platform has. A reader of the spec could + reasonably conclude ObjectStack sandboxes dynamically loaded plugins. It does + not. + + That is the ADR-0049 false-compliance shape, and the precedent for a + SAFETY-shaped instance is to remove rather than mark dead: + `tool.requiresConfirmation` was pruned in #3715 because it was "unenforced on + every path, so it was false compliance, not merely dead". This is the same case, + one layer up. + + Found while building the empty-state gate (#3945): `allowedSources` documented + `[]` as admitting every source, and checking who enforced that turned up nobody. + + **No `retiredKey()` tombstones, deliberately.** A tombstone earns its keep by + making a removal audible at a parse the author actually reaches — and nothing + parses these schemas, so the prescription could never be delivered. The + silent-strip that the key-vanish guard exists to prevent was already these keys' + permanent condition: writing one has always been a no-op, because no parent + schema ever accepted them. The guard's baseline entries in + `json-schema.manifest.json` and `authorable-surface.json` are therefore dropped + in this PR as the deliberate removal both files document as the legitimate path, + rather than tombstoning 15 keys nobody could have successfully authored. + + **Breaking, in the narrow sense.** `packages/spec/src/kernel/index.ts` + re-exports this module with `export *`, and `./kernel` is a published subpath, so + `DynamicLoadingConfig`, `PluginDiscoveryConfig`, `PluginDiscoverySource` and + their schemas were importable as types. Nothing in this repo imported them. + Marked `major` because removing a public export is breaking regardless of use; + in practice it folds into the unreleased 17.0.0. + + The rest of `plugin-runtime.zod.ts` is untouched — including + `ActivationEventSchema`, the one export in the file with real consumers. Note + that the remainder (`DynamicLoadRequest`, `DynamicUnloadRequest`, + `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`) also has no + runtime consumer today; it is left in place because those are operation + contracts, not security promises, and the enforce-or-remove call on them is a + design decision rather than a correction. + + **Rebuilding this surface is a design job, not a schema job**: write the runtime + first, then declare only what it enforces. + +- ec796d5: feat(spec)!: retire `BatchOptions.validateOnly` — a dry-run flag that was never implemented (#4052) + + `BatchOptions.validateOnly` promised a dry-run — "validate records without + persisting changes" — but no batch surface ever read it. `updateManyData`, + `deleteManyData` and `batchData` all persist regardless, so a caller sending + `options.validateOnly: true` to PREVIEW a mutation got it executed. That is the + dangerous direction of "declared ≠ enforced": a flag lying about a data-safety + guarantee, not merely an inert no-op. + + There is no dry-run today. Rather than back-fill an implementation to match a + promise nothing kept — a real no-commit batch has its own design space (cascade + and constraint semantics under rollback, a response contract that reports each + row's would-succeed verdict) — the key is retired so it can be reintroduced + deliberately when there is a real need. + + **Breaking change.** + + - `BatchOptions.validateOnly` is a retired key. It is tombstoned (`retiredKey`) + in `BatchOptionsSchema`, so authoring it now fails with a fix-it prescription + rather than being silently stripped (the ADR-0104 / #3733 quiet-failure class). + The `BatchOptions` type's `validateOnly` becomes `never`. + - The retirement is HTTP-only (the key never appeared in stored stack metadata), + so it is recorded as a semantic migration on the protocol-17 chain step + (`batch-options-validate-only-retired`) — a TODO for API callers, not a stack + conversion. + + **Migration.** Stop sending `options.validateOnly` on `/batch`, `/updateMany` + and `/deleteMany`. It never previewed anything; removing it changes no behaviour. + If you need to validate a batch without writing, follow #4052 so a real + no-commit preview can be designed. + + Also fixes a dangling documentation reference: the `/createMany` route + registration named `requestSchema: 'CreateManyRequestSchema'`, a schema no + module ever exported — pointed at the real `CreateManyDataRequestSchema`. + +- e87fea1: **BREAKING**: the legacy `_dev: true` service marker is retired. `readServiceSelfInfo()` + now reads exactly one marker — the standard `__serviceInfo` descriptor — and the + `SERVICE_DEV_MARKER_KEY` export is removed. + + FROM → TO, for any service that self-identifies as not-fully-real: + + ```ts + // FROM — normalized to { status: 'stub', handlerReady: false } + const svc = { _dev: true, chat }; + + // TO — say which kind of unreal it is + const svc = { + __serviceInfo: { + status: "stub", // 'stub' = fabricates answers | 'degraded' = really serves, reduced capability + message: + "Development stub — register for a real implementation", + }, + chat, + }; + ``` + + `handlerReady` defaults to `false` for `stub` and `true` for `degraded`; set it + explicitly when the slot has no HTTP surface at all (`cache` / `queue` / `job`). + + **Why it matters if you skip the migration:** a service still carrying `_dev: true` + reads as _unmarked_ — i.e. as fully real — so discovery will report it + `status: 'available', handlerReady: true`, and dispatcher domains will call it + instead of refusing it. That is the "fake reported as real" failure ADR-0076 D12 + exists to prevent, so migrate rather than leave the marker in place. + + Removing rather than aliasing is deliberate: a boolean cannot express the + `stub` / `degraded` split every consumer gates on (a stub's domain refuses it, a + degraded implementation's domain keeps serving it). No producers remained in this + repo when the reader was deleted — plugin-dev's stub table was retired in + ADR-0115, and the kernel's in-memory fallbacks moved onto the descriptor in the + same lineage. + +- 3ca34c1: refactor(spec)!: retire three orphan operator vocabularies (objectui#2945 Track A) + + An audit of every comparison/aggregation vocabulary the spec ships + (objectstack-ai/objectui#2901) found the operator vocabularies had multiplied + past what any code consults. Three had **no importer at all** — not in this + repo, not in objectui, not in cloud — and each contradicted the vocabulary that + is actually enforced. Removed rather than reconciled: a second name for one + concept is how they drifted apart in the first place. + + **`AggregationFunctionEnum`** (`shared/enums.zod.ts`). Its own doc comment + claimed it was _"used across query, data-engine, analytics, field"_. It was used + by nothing. `AggregationFunction` (`data/query.zod.ts`) is the vocabulary the + query engine, `service-analytics`' dataset compiler and the native-SQL strategy + all gate on — and the two disagreed: this one carried + `percentile`/`median`/`stddev`/`variance`, that one carries + `array_agg`/`string_agg`. It also exported a _type_ named `AggregationFunction` + while `data/query.zod.ts` exports a _value_ of that name, so the two occupied + the same identifier in different declaration spaces with different members. + + **`FilterOperator`** + `EventFilterCondition` + `EventFilterSchema` + (`api/websocket.zod.ts`), reached from `EventSubscriptionSchema.filters`. No + runtime ever evaluated an event filter — `matchesSubscription` matches on object + name and event type only (`contracts/realtime-service.ts`) — and the + subscription shape the transports actually carry is the separate, deliberately + unvalidated `filters: z.unknown()` on `SubscriptionEventSchema` + (`api/realtime.zod.ts`). So this was a _second_ modelling of event filtering + that described a capability no code provided: a subscriber who set `filters` + received every event regardless. + + The `filters` **key stays**, now typed `z.unknown()` with the same + NOT-YET-ENFORCED marker as its `api/realtime.zod.ts` counterpart. Retiring an + object key requires a tombstone plus a conversion (ADR-0104), which is the right + rule and the wrong trade here — there is no author to migrate for a shape nothing + validated, and Track A is meant to carry no migration. The two subscription + surfaces now describe event filtering identically, and neither implies an + enforcement that does not exist. Whichever grows real filtering should lower onto + `AST_OPERATOR_MAP` rather than reintroduce a vocabulary of its own. + + **`ODataFilterOperatorSchema`** (`api/odata.zod.ts`). Nothing parses an OData + `$filter` against it — `$filter` is carried as an opaque string on + `ODataQuerySchema` and as the `odata` adapter template in + `query-adapter.zod.ts` — and an enum mixing operators with `(`/`)` could not + validate an expression anyway, since it describes tokens, not a grammar. A real + implementation needs a parser, and that parser should lower onto + `AST_OPERATOR_MAP` like every other entry point. + + **Breaking, in the narrowest sense.** All three were reachable as public + exports (`@objectstack/spec/shared` and `@objectstack/spec/api`), so this is a + `major`. No consumer exists to break: verified by grep across framework + `packages/` + `apps/`, objectui, and cloud. Nothing is _narrowed_ — no accepted + value stops being accepted, so no already-stored metadata or in-flight payload + changes meaning. That is what made this the one track of objectui#2945 that was + safe to start; narrowing `VALID_AST_OPERATORS` or retiring a + `VIEW_FILTER_OPERATORS` alias is not, and remains blocked on #3948. + + The generated artefacts move with the deletions, as the ratchets require: + `json-schema.manifest.json` drops the five unpublished schemas, + `authorable-surface.json` the seven keys of the two deleted objects, + `api-surface.json` the eight exports, and the three reference-doc pages are + regenerated. + + Verified: full `@objectstack/spec` suite **6917 tests across 266 files**, plus + `tsc --noEmit`, `check:docs`, `check:api-surface`, `check:authorable-surface` and + `check:skill-docs`, all clean. + +- 239c3a3: fix(spec)!: the #3963 / #4052 / #4158 / #4196 / #4286 retirements land in protocol **17**, not a protocol 18 that this train cannot produce (#4350) + + Ten tombstone prescriptions told authors a key "was removed in `@objectstack/spec` **18**", + and — worse — the machine agreed with them: a whole `step18` chain step and two + `toMajor: 18` conversions were wired for a major the release train does not reach. + + **17 is what ships.** `latest` is 16.1.0 and `rc` is `17.0.0-rc.0` — 17.0.0 has never been + published. `.changeset/pre.json` records `@objectstack/spec` at initialVersion 16.1.0, and + changesets computes a pre-mode bump from the last _published_ version: 16.1.0 + `major` = + **17.0.0**, released as `17.0.0-rc.N`. `PROTOCOL_VERSION` is `'17.0.0'`, and + `protocol-version.test.ts` pins it to the package major, so it cannot unilaterally become 18 + either. The "18" came from counting up from the in-flight `17.0.0-rc.0` instead of from + 16.1.0. + + **The prose was the smaller half.** `composeMigrationChain(from, to = PROTOCOL_MAJOR)` + filters `m <= toMajor`, so a step keyed 18 was **unreachable**: `os migrate meta --from 16` + walked steps 11–17 and silently skipped 18. The same ceiling applies to `composeSpecChanges`, + so the generated `spec-changes.json`, `docs/protocol-upgrade-guide.md` and the `spec_changes` + MCP tool — the ADR-0087 D4 primary channel — carried **none** of these seven retirements: + `query.joins`, `query.windowFunctions` and `BatchOptions.validateOnly` appeared zero times in + the committed manifest, and the upgrade guide contained no "18" at all. Authors would have hit + the tombstones with no chain hop to run and no upgrade-guide row to read. + + What changed: + + - `step18` is folded into `step17` — its rationale, both `conversionIds` + (`stack-api-require-auth-removed`, `flow-node-wait-timeout-keys-removed`) and all six + semantic migrations move across, and `MIGRATIONS_BY_MAJOR[18]` is gone. Both conversions + become `toMajor: 17` (`migrations.test.ts` requires a conversion's `toMajor` to equal its + step's major), and `CONVERSIONS_BY_MAJOR[18]` merges into `[17]`. + - All 30 hand-written "18" references become "17": the ten tombstone prescriptions + (`query.zod.ts`, `flow.zod.ts`, `rest-server.zod.ts`, `stack.zod.ts`, `protocol.ts`), the + `query.test.ts` pin regex that was holding the wrong number in place, the internal comments, + the `liveness/query.json` + `liveness/README.md` notes, and the seven unconsumed changesets. + - The seven retirements are written into the v17 release notes and upgrade checklist, where + they had no entry at all — there is no `v18.mdx` for them to have landed in. + + No behaviour is added or withdrawn: every key retired by #3963, #4052, #4158, #4196 and #4286 + stays retired, on exactly the terms those changesets describe. What changes is that the + prescription now names the version that will actually carry it, and `os migrate meta` actually + applies the two stack conversions instead of stepping over them. + +- d6bfb3d: refactor(spec)!: remove the RLS-policy `priority` key — it promised conflict resolution that cannot exist (#3896 audit) + + `RowLevelSecurityPolicySchema.priority` was documented as _"Policy priority for + conflict resolution"_. The 2026-07-30 security-subset liveness re-verification + found that **nothing ever read it** — and, stronger, that nothing ever could: + applicable policies **OR-combine** (any match allows access, most permissive + wins — the schema's own describe said so), so there is never a conflict to + order and evaluation order cannot change an outcome. A semantically-void knob + on a security policy is worse than dead: an author — very often an AI + (ADR-0033) — reads it as a precedence lever and reasons about policy + interactions that do not exist. + + Removed per the `tool.requiresConfirmation` (#3715) / `DynamicLoadingConfig` + (#3950) precedent, inside the v17 breaking window: + + - **Tombstoned, not silently stripped** (`retiredKey`, #3855 pattern): an + authored `priority` fails `tsc` (the input type is `never`) and rejects at + parse with the prescription itself — _"policies OR-combine (most permissive + wins), so there is no conflict to order. Delete the key — policy outcomes are + unchanged."_ + - **ADR-0087 D2 conversion + D3 chain step** (`permission-rls-priority-removed`): + `os migrate meta` deletes the key from authored sources mechanically — a pure + lossless delete, no semantic residue. spec-changes.json and the protocol + upgrade guide carry the entry. + - The policy factory helpers (`ownerPolicy`, `tenantPolicy`, …), the showcase + example's permission sets, and `content/docs/permissions/rls.mdx` no longer + author it; the docs table's `enabled` row now states the (since-enforced) + contract instead. + - Liveness ledger entry updated to record the removal; the tombstone and entry + age out ~two majors from now. + + Dropping the key changes **no policy outcome anywhere** — that impossibility of + effect is the entire reason for the removal. + +- eb95d97: refactor(spec)!: remove the four inert tool authoring keys — two of them promised safety they never delivered (#3896 close-out) + + `tool.category`, `tool.permissions`, `tool.active` and `tool.builtIn` were + authorable and inert: none is part of `AIToolDefinition`, and no execution path + read them. The liveness ledger had already corrected all four to dead+authorWarn + (#3686); this finishes the enforce-or-remove disposition inside the v17 window, + following the `requiresConfirmation` precedent (#3715) — because two of the four + were misleading in the dangerous direction: + + - **`permissions`** promised a capability gate on tool invocation. Nothing + enforced it — a tool "requiring" capabilities ran for everyone. The real gates + are `action.requiredPermissions` (ADR-0066) and permission sets on the objects + the tool touches. + - **`active: false`** read as "withdrawn". It withdrew nothing: `ToolRegistry.getAll()` + returns everything, the tool kept reaching the LLM tool set, and + `POST /ai/tools/:name/execute` kept running it — unlike `agent.active` / + `skill.active`, which are enforced. To withdraw a tool, remove it from the + skills/agents that reference it. + + The retirement kit: + + - The `.strict()` ToolSchema rejects each retired key with its own prescription + (`TOOL_RETIRED_KEY_GUIDANCE`, the #3715 pattern) — no silent strip. + - **ADR-0087 D2 conversion + D3 chain step** (`tool-inert-authoring-keys-removed`): + `os migrate meta` deletes the keys mechanically; a pure lossless delete, since + they never had any effect to lose. + - `ToolCategorySchema` / `ToolCategory` are removed with the key they typed + (zero consumers; `action.zod.ts` deliberately keeps its own inline vocabulary). + - The Studio tool form drops its inputs for the retired keys — a form input for + an unenforced gate is the UI half of false compliance, the same + "advertising the failure mode" shape objectui#2962 removed from the + sharing-criteria builder. + - Ledger entries deleted per the #3715 precedent; baselines + (`authorable-surface.json`, `json-schema.manifest.json`) updated deliberately; + reference docs and the v17 release notes regenerated/extended. + + No runtime behaviour changes — that impossibility is the reason for the removal. + +- 4d7bebf: feat(spec)!: reject unknown keys on RLS policies, sharing rules, and positions (#4001 step 2) + + Second click of the unknown-key strictness ratchet (first: flow + permission, + #4071), extending `.strict()` + the `strictUnknownKeyError` fixable-error + factory to the remaining small security-class authoring surfaces, per + `docs/audits/2026-07-unknown-key-strictness-ledger.md`: + + - **`security/rls.zod.ts`** — `RowLevelSecurityPolicySchema` is `.strict()`. + A silently dropped key on an RLS policy meant a row-level restriction the + author wrote was never compiled into the filter. The runtime shapes + (`RLSUserContextSchema`, `RLSEvaluationResultSchema`) stay tolerant. The + retired `priority` key keeps its existing tombstone. + - **`security/sharing.zod.ts`** — the sharing-rule surface is `.strict()` + (base + criteria extension + the `sharedWith` recipient shape). A silently + dropped key meant a share the author intended was never materialised. + - **`identity/position.zod.ts`** — `PositionSchema` is `.strict()`, and gains + the author-facing `protection` block plus the ADR-0010 runtime protection + envelope (`_lock`, `_packageId`, `_provenance`, …) — closing the sibling + gap the #4071 ledger flagged: `applyProtection` stamps every registered + metadata type, and position was the last one whose schema could not + represent the stamp. + + **Migration.** Any key these schemas now reject was previously stripped and + had **no runtime effect** — removing or renaming it never changes behavior. + The error carries the fix; FROM → TO mappings baked in include: + + - RLS policy: `roles`/`role` → `positions` (ADR-0090 D3 rename), + `withCheck` → `check` (the PostgreSQL spelling), `condition`/`filter`/`where` + → `using`. `priority` stays a tombstone (#3896: OR-combined policies have no + precedence to order — delete the key). + - Sharing rule: `criteria` → `condition` (the persisted row spells the + compiled predicate `criteria_json`; the authored key is the CEL + `condition`), `access`/`level` → `accessLevel`, + `recipient`/`shareWith`/`sharedTo` → `sharedWith`, `enabled` → `active`; + recipient `id`/`target` → `value`. `ownedBy` carries the removed + owner-type-rule prescription (only `criteria` rules are authorable). + - Position: `title` → `label`; `permissionSets` / `users` are runtime + bindings (`sys_position_permission_set` / `sys_user_position`), never + authored on the position; `parent` is rejected with the flatness rule + (ADR-0090 D3 — hierarchy is the business-unit tree, not a position tree). + +- 821ac7a: feat(spec)!: reject unknown keys on the approval authoring schemas (#4001 step 3) + + Third click of the unknown-key strictness ratchet (flow + permission in + #4071, RLS / sharing / position in #4099). Approval is a v17-new authoring + surface — tightened while young, before stored volume exists: + + - **`automation/approval.zod.ts`** — `ApprovalNodeConfigSchema`, + `ApprovalNodeApproverSchema`, `ApprovalEscalationSchema`, and + `DecisionOutputDefSchema` are `.strict()` with fixable errors. An approval + gate that quietly ignores half its config is the worst instance of the + ADR-0078 trap — the request routes, but not the way the author declared. + - The published JSON schema (`getApprovalNodeConfigJsonSchema`) now carries + `additionalProperties: false` into the Studio property form AND + `registerFlow()`'s per-node config validation (#4027/#4040), so an unknown + key inside an approval node's `config` is rejected at registration too. + + **Migration.** Any key now rejected was previously stripped and had no + runtime effect — removing or renaming it never changes behavior. Mappings + baked into the errors include the ADR-0019 re-home map for process-era + concepts: `steps` → successive approval NODES on the canvas, `entryCriteria` + → the condition on the entering edge, `onApprove` / `onReject` → the nodes + wired to the `approve` / `reject` out-edges, `rejectionBehavior` → a declared + back-edge (ADR-0044) with `maxRevisions`. Plus spelling aliases: + `mode` / `approvalMode` → `behavior`, `quorum` → `minApprovals`, + `statusField` → `approvalStatusField`, `org` → `organization`, + `expandAs` → `resolveAs`, `timeout` / `hours` / `sla` → `timeoutHours`, + `to` / `target` → `escalateTo`, `name` → `key`, `widget` → `type`. + +- 8f81731: feat(spec)!: reject unknown keys on the flow and permission authoring schemas (#4001 Tier-A) + + Zod's default `.strip` silently discarded any key these schemas did not + declare — the instance kept parsing, so a mis-spelled or wrong-layer key + shipped as metadata that quietly ignored the author's config (#3405's + action-param `reference`, #1535's object-level `workflows`). #3746 tightened + one schema; this extends the same treatment to the two highest-risk + authorable surfaces, per the ADR-0054 ratchet and the + `docs/audits/2026-07-unknown-key-strictness-ledger.md` triage: + + - **`security/permission.zod.ts`** — `PermissionSetSchema`, + `ObjectPermissionSchema`, `FieldPermissionSchema`, `AdminScopeSchema` are + now `.strict()`. A silently dropped key on the capability container meant + the author believed a grant or restriction was in place that the runtime + never saw. `EffectiveObjectPermissionSchema` (response-side) explicitly + `.strip()`s back and stays wire-tolerant. + - **`automation/flow.zod.ts`** — `FlowSchema`, `FlowNodeSchema`, + `FlowEdgeSchema`, `FlowVariableSchema` are now `.strict()`. A node's + `config` record stays **open**: it is per-node-type, owned by the + registered executor's `configSchema` (#4027/#4040) and the ADR-0087 + conversion layer. + - **`shared/suggestions.zod.ts`** — new `strictUnknownKeyError` factory (the + #3746 hand-rolled map, generalized): every rejection names the offending + key(s) and, where recognisable, the canonical spelling or a retired-key + tombstone. `ui/action.zod.ts` re-homes onto it with byte-identical messages. + - **`PermissionSetSchema` gains `description`, `protection` and the ADR-0010 + runtime protection envelope (`_lock`, `_packageId`, `_provenance`, …).** The + strict gate's own catches: all of these are written by real code — the + built-in default sets author `description` and the Setup projection reads + it; `applyProtection` stamps the envelope on every metadata type and + `getMetaItemLayered` → `saveMetaItem` round-trips it — but the schema could + not represent them, so they were silently stripped at every parse (ADR-0078 + §3 inverse drift). Every sibling registered metadata type already spread + `MetadataProtectionFields`; permission was the outlier. + + **Migration.** Any key these schemas now reject was previously stripped and + therefore had **no runtime effect** — removing or renaming it never changes + the behavior of a working app; validation simply stops lying about it. The + error message carries the fix; the FROM → TO mappings baked into it include: + + - Permission set: `objectPermissions`→`objects`, `fieldPermissions`/`fls`→`fields`, + `tabs`→`tabPermissions`, `rls`/`policies`→`rowLevelSecurity`; + `read`/`edit`/`export`/…→`allowRead`/`allowEdit`/`allowExport`/…; + `readable`/`editable` vocabulary for FLS (`hidden` → declare `readable: false`). + Retired keys carry tombstones: `contextVariables` (ADR-0105 D11 — use a + registered `rlsMembership` resolver or an inline literal), `isProfile` + (ADR-0090 D2 — use `isDefault`). + - Flow: `steps`→`nodes`, `connections`/`transitions`/`links`→`edges`, + `trigger`/`triggerType`→`type`, `title`→`label`; edge `from`/`to`→`source`/`target`, + `guard`/`when`/`expression`→`condition`; a top-level `object`/`objectName`/`schedule` + belongs on the START node's `config` (`{ objectName, triggerType, condition, +schedule }`), not on the flow. + +- ab16331: feat(spec)!: retire `waitEventConfig.timeoutMs` / `.onTimeout` — `wait` never had a timeout (#4158) + + Both keys described a timeout and neither delivered one, so protocol 17 removes the pair + rather than leaving a promise the runtime does not keep (PD #10). + + - **`onTimeout`** had **zero** readers. No path ever inspected it, so neither `'fail'` nor + `'continue'` ever happened — and its `.default('fail')` stamped a decision nothing made + onto every wait node. The showcase set `onTimeout: 'continue'`, which did nothing. + - **`timeoutMs`** said _"maximum wait time before timeout"_ while its only reader used it + as the timer **duration** when `timerDuration` was absent. It did something, just not + what it claimed. + + Together they declared a timeout `wait` does not have: a run resumes when its timer + elapses or its signal arrives, never on a deadline. Real timeout semantics are left + unimplemented deliberately — they should be built to a requirement, not retrofitted to + fit two keys that happened to be declared. + + `timeoutMs` **converts to `timerDuration`** rather than being dropped, because that is + what it did. It is stringified on the way: the target is `z.string()` while `timeoutMs` + was `z.number()`, and `parseIsoDuration` reads a bare numeric string as milliseconds — so + `timeoutMs: 60000` and `timerDuration: '60000'` are the same wait. Moving the number + unstringified would have produced a block that no longer parses, which a test pins. With + `timerDuration` already set it is dropped instead: the executor's `??` never looked past + the duration, so it was already dead metadata. + + Both leave the **load path** (`retiredFromLoadPath`), which is the registry's existing + split: a key retired for being _renamed_ keeps a load window, because punishing an author + for a spelling nobody warned them about is pointless; a key that **misdescribed itself** + does not, because silently absorbing it lets the author keep believing they configured a + timeout. That is why `api.requireAuth`, the tool/app/flow inert keys and RLS `priority` + all left it too. The migration chain converts stored sources mechanically; the schema + tombstones name the replacement. + + One fixture interaction worth recording: the #4045 lift fixture used + `waitEventConfig.timeoutMs` to demonstrate its fourth ledger entry, and the fixture + harness replays the whole table — so its `after` described an end state protocol 17 makes + unreachable. It now lifts `eventType` instead. The harness caught this itself. + +### Minor Changes + +- 270650f: feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum) + + Deployment-level migration flags could only be recorded by running + `os migrate`. That left a hole at the other end of a deployment's life: a + database created on a version that already ships the migrations started **lax** + and stayed lax until someone thought to run a command that, for them, converts + nothing and finds nothing. Every new deployment re-entered the warn regime, so + the warn regime would never die out — and, since #3459, every new deployment + also kept every released file forever. + + A store the platform **creates from empty** now records + `adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing + to run; enforcement and collection are live from the first boot. + + **This is not version-gating in disguise.** The fact recorded — no legacy value + is stored here — is _observed_: the store had no history at all. The platform + attests only what it watched itself create, and the test is deliberately + strict: every table made by this boot and **none found already present**. One + pre-existing table anywhere, one datasource that was already there, one driver + that cannot account for its schema sync — any of those and the deployment + attests nothing and produces its evidence by scan, exactly as before. "Found + empty" and "created empty" are not the same claim, and only the second is an + observation. + + **New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely + observational: tables created vs found since connect — implemented by the SQL + and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`, + `attestFreshDatastore()` in `@objectstack/platform-objects/system`, and + `VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in + `@objectstack/spec/system`. Attestation never overwrites an existing flag row + and never throws into a boot: a failure leaves the deployment lax, which a + migration run can still fix. + + **Upgrading changes nothing for an existing database.** It is non-empty when + the platform reaches it, so it is never attested — run + `os migrate files-to-references --apply` as before. Importing legacy values + into an attested deployment is rejected loudly at the write path; + `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose. + +- 3aef718: feat(migrate): `os migrate value-shapes` — the per-deployment gate for reference and structured-JSON value shapes (#3438) + + The second of ADR-0104 D1's two evidence gates. Media value shapes already + enforce once a deployment has verified its file migration (#3681); the + reference (`lookup` / `master_detail` / `user` / `tree`) and structured-JSON + (`location` / `address` / `composite` / `repeater` / `record` / `vector`) + classes now get a gate of their own. + + ```bash + os migrate value-shapes # scan: reports, writes nothing + os migrate value-shapes --apply # scan + record the deployment flag when clean + ``` + + The run walks every stored value of those classes against + `valueSchemaFor(field, 'stored')` — the same predicate the write path enforces, + imported rather than re-derived — and, at zero violations, records + `sys_migration { id: 'adr-0104-value-shapes', verified_at, blocking: 0 }`. + Strict enforcement of these classes reads **that row**, never the platform + version, so upgrading changes nothing until a deployment produces its own + evidence. + + **There is no backfill, deliberately.** The file migration converts legacy + values because the platform narrowed that storage form and owes the conversion. + A malformed `location` is application data whose correct value only its author + knows, so this run reports and prescribes — naming the object, field, type, + count, offending record ids and the parse issue — and the operator fixes and + re-runs. With nothing to convert, `--apply`'s only write is the flag row, which + keeps the #3617 invariant trivially: a dry run changes nothing, and whether a + run changed this deployment's posture never depends on what it found. + + **A separate flag from the file migration**, because it attests a separate + fact. That flag says file values were migrated and their ownership reconciled; + it says nothing about whether a `lookup` id or a `location` payload is well + formed. Gating these classes on it would be borrowing evidence for a fact it + does not cover. + + - New escape hatch **`OS_ALLOW_LAX_VALUE_SHAPES=1`** returns a verified + deployment to warnings, with the same precedence as its media sibling: the + opt-out beats `OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, which beats the flag. + Wrongly staying lenient costs a warning; wrongly enforcing stops a working + app from writing. + - `@objectstack/spec/system` exports `VALUE_SHAPES_MIGRATION_ID`. + - `@objectstack/objectql` exports `scanValueShapes`, `valueShapeScanPassed` + and `formatValueShapeScanReport`. The scanner is read-only and does **not** + record the flag: readers of a migration flag use the spec contract, only + writers depend on `@objectstack/platform-objects`, so the composition lives + with the CLI command rather than inverting the engine's dependencies. + - `validateRecord` gains `valueShapeStrict`, the sibling of + `mediaValueShapeStrict`. Both default to `false`: a caller that cannot say + stays lenient, so nothing starts rejecting merely because the evidence was + unavailable. + + **Nothing changes for an existing deployment until it runs the command.** A + scan that is truncated, or that cannot read an object, fails the gate even with + zero violations found — "none in the part we read" is not the claim the flag + makes. + +- 1ea6bce: feat(sharing): hierarchy managers may manage shares within their write DEPTH (ADR-0111 D1 DEPTH) + + `canManageShares` gains its named DEPTH extension: a caller whose effective + WRITE scope on the object is a hierarchy scope (`unit` / `unit_and_below` / + `own_and_reports`) may now manage shares on a record whose owner falls within + that scope's owner set — the same set the write filter and `canEdit` already + honour, resolved by the enterprise `hierarchy-scope-resolver`. This lets a + manager grant/revoke/list shares on a subordinate's record, matching + Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU + depth), without expanding the MVP owner + Modify-All authority. + + - New `ISecurityService.resolveWriteScope(object, context)` — the effective + write scope, resolved by the same evaluator the CRUD middleware uses; fails + closed to `own`. Mirrored on the sharing plugin's structural probe. + - The gate honours only the three hierarchy scopes. `org` from the probe is + deliberately ignored: it means both a genuine Modify-All holder (already + granted via `hasWriteBypass`) AND the fail-OPEN "no permission set mentions + this object" default, so honouring it here would reopen the hole + `hasWriteBypass` was chosen to avoid. + - Fails closed with no security service or no enterprise resolver — the open + edition stays owner + Modify-All, exactly as before. + +- c1dcacd: fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902) + + Record sharing shipped as a data layer with no authorization of its own: every + `/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller + and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke + anyone's share, enumerate who-can-see-what, write self-grants, and define / + evaluate org-wide sharing rules. ADR-0111's P0 rulings land here: + + - **D1/D2** — `ISharingService.canManageShares(object, recordId, context)`: + system, the record's owner, or a holder of Modify All Data (probed via the + new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE, + so every caller is covered; without plugin-security it fails closed to + owner-only. + - **D4** — `revoke` is symmetric with grant, validates the share belongs to the + URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows + (`CONFLICT` — a rule-materialised grant would be resurrected by the next + reconcile). + - **D5** — `listShares` is management-gated (invisible record → `NOT_FOUND`, + visible-but-not-manager → `PERMISSION_DENIED`), and the open + `/data/sys_record_share` read surface is self-scoped: non-admin callers see + only rows naming them as recipient or grantor. + - **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate) + requires the new **`manage_sharing`** capability (D9; seeded into + `admin_full_access`, `manage_platform_settings` honoured as the legacy + equivalent), enforced in `SharingRuleService`. + - **D7** — no inert grants: `recipientType` is narrowed to `user` (the only + type any gate enforces), grants on objects the sharing gates never consult + (public model, no `owner_id`, bypass, `controlled_by_parent`) fail with + `SHARING_NOT_ENABLED` (422), and the manual upsert keys on + `(object, record, recipient, source)` so manual and rule rows coexist. + + **Breaking** for callers that relied on the missing gate: unauthorized share + management now fails with 403/404/409/422 instead of silently succeeding, and + `ISharingService.revoke` gained an optional `scope` parameter. The verb + boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the + separate P1. + +- ad303ed: fix(sharing)!: an edit-level share no longer grants delete (ADR-0111 D3, the verb boundary) + + `update` and `delete` shared one `canEdit` gate, and `canEdit` accepts an + `edit`-level share — so one "edit" grant silently conferred delete, the + opposite error from the retired `full` level. A share widens _which rows_ a + principal reaches, never _which verbs_ they may use (Salesforce Read/Write + cannot delete; Dataverse `Delete` is a distinct privilege; Odoo splits + `write`/`unlink`). + + - `ISharingService.canDelete(object, recordId, context)` — ownership (widened + by write DEPTH) or the `modifyAllRecords` super-user bypass ONLY; an `edit` + or legacy `full` share does not confer it. `canEdit` is unchanged (the + update gate, share included). + - `SharingService.buildWriteFilter` takes a `verb` parameter: a bulk + `delete({multi:true})` scopes to the owner/DEPTH set alone (no share + widening), while a bulk `update` keeps it. + - The sharing middleware routes `delete` through `canDelete` and logs a + specific fail-closed reason on denial (ADR-0111 D10). + - `/security/explain` consults `canDelete` for a `delete` operation, so the + record-level explanation matches enforcement. + + **Breaking**: a caller who could delete a record _only_ through an edit-level + share (and holds object-level delete CRUD) can no longer delete it — delete now + requires ownership, write depth, or Modify All Data. No new delete access level + is introduced; a future per-record delete grant would be a capability mask + AND-ed with object CRUD, not a fourth share level. + +- 32ccb23: feat(spec,core,runtime)!: ADR-0112 batch 1 — one error-code vocabulary, SCREAMING_SNAKE, schema-enforced (#3841) + + Settles #3841 per ADR-0112: the top-level `error.code` vocabulary is + SCREAMING_SNAKE, in two tiers. + + - **`StandardErrorCode` members renamed in place** (`validation_error` → + `VALIDATION_ERROR`, all 53). Breaking for importers that branch on the old + lowercase members; the type name and member _meanings_ are unchanged. + - **New `ERROR_CODE_LEDGER`** (`@objectstack/spec/api`): service-specific codes + (`AUTH_REQUIRED`, `VALIDATION_FAILED`, `ATTACHMENT_DOWNLOAD_DENIED`, …) are + registered per owning package. `ErrorCode` = standard ∪ registered. + - **`ApiErrorSchema.code` is now `ErrorCode`**, not `z.string()` — an + unregistered code fails parse, so the envelope conformance suites assert + values, not just shape. + - **`FieldErrorSchema.code` widened to `z.string()`** (ADR-0112 D6): field-level + codes are a separate vocabulary the enum never described; #3977 owns its real + catalog. + - **Derived codes changed case on the wire**: `standardErrorCodeForHttpStatus` + now yields SCREAMING members (`permission_denied` → `PERMISSION_DENIED`, + `method_not_allowed` → `METHOD_NOT_ALLOWED`, …) — this map was #3842's + designated one-file sweep point for exactly this decision. + - **`ANONYMOUS_DENY_CODE` is `'UNAUTHENTICATED'`** (was `'unauthenticated'`) — + the promoted code on anonymous-denied requests and the REST `enforceAuth` + body change spelling with it. + + `error-catalog.mdx` and the error-handling guides are rewritten to the single + vocabulary; a spec test now locks the catalog page's headings to the enum so + they cannot drift apart again. Remaining lowercase emitters (cloud-connection, + plugin-auth envelope codes, metadata-protocol, …) are the batch-2 sweep. + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +- 7d7521f: feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) + + Settles the vocabulary ADR-0112 D6 deferred, per [ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md). + + **`FieldErrorCode` — a closed, lowercase catalog.** 27 members covering what the + six emitters already emit. `FieldErrorSchema.code` tightens from `z.string()` to + this enum, so a validation body's per-field codes are validated for the first time. + `FieldValidationError.code` (objectql) and `FieldCoerceError.code` (rest) stop + being a hand-listed union and a bare `string` respectively and reference the + catalog, so the three cannot drift apart. + + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a + top-level code names the condition the _request_ hit, while a field-level code + names the _constraint_ the value violated — and constraints are declared in the + metadata's own snake_case, so `max_length` the code and `max_length: 50` the + property are the same word on purpose. + + **Zod issue codes no longer reach the wire (wire-visible).** Routes that validate + with Zod passed its vocabulary straight through, so `fields[]` spoke a different + language depending on which route served it, and `too_small` was ambiguous between + a short string, a small number and a short array. `zodIssuesToFields` now maps + using Zod's `origin`/`format`: + + | Was | Now | + | :------------------------------------------------ | :------------------------------------------------- | + | `too_small` | `min_length` / `min_value` / `min_items` | + | `too_big` | `max_length` / `max_value` / `max_items` | + | `invalid_format` | `invalid_email` / `invalid_url` / `invalid_format` | + | `invalid_value` | `invalid_option` | + | `unrecognized_keys` | `unknown_field` | + | `invalid_union`, `invalid_element`, `invalid_key` | `invalid_shape` | + + **A missing required property now reports `required`, not `invalid_type`.** Zod + spells "absent" as a type mismatch against `undefined`, so passing it through made + a form mark a _missing_ input as the wrong _type_. The two are indistinguishable on + the issue alone, so the mapper takes the parsed input as an optional argument and + walks the issue path; a caller that cannot supply it keeps `invalid_type` rather + than guessing. + + **`unknown_param` → `unknown_field`.** `ActionParamIssue.code` references the + catalog instead of its own literal union; the `param` key beside it already says + what was addressed. + + **Not changed:** `EnhancedApiErrorSchema.fieldErrors` keeps its name even though + every producer emits `fields`. Retiring an authorable key needs a tombstone plus a + migration (ADR-0104's contract guard), so it lands on its own — the property now + carries a banner saying which name the wire uses. + +- 91f4c78: feat(approvals,spec): structured reassign hand-off parties on `sys_approval_action` (#4365) + + A reassign's audit row used to encode "who handed the slot to whom" only inside + a default free-text comment — `""`, two raw user ids — which + clients could neither parse reliably nor render readably, so the approvals + timeline showed opaque identifier soup for the single most important fact of + the entry. + + - `sys_approval_action` gains `reassign_from` / `reassign_to` + (`lookup('sys_user')`), written by `ApprovalService.reassign()`. + - `comment` is pure user input again: nothing is invented when the actor + supplies none. + - `listActions()` resolves both parties' display names into + `reassign_from_name` / `reassign_to_name`, alongside the existing + `actor_name`, so timelines can render "from A to B" without extra lookups. + - `ApprovalActionRow` (spec contract) declares the four new fields. + + Pre-existing rows keep their legacy comment; clients should prefer the + structured fields when present and fall back to `comment` otherwise. + +- 820eff9: fix(spec,plugin-approvals): the two approval vocabularies are derived, not hand-matched (#3786) + + `sys_approval_request.status` and `sys_approval_action.action` spelled their + option lists out — five values and twelve — each under a "Keep in sync with + `ApprovalStatus` / `ApprovalActionKind` (spec/contracts)" comment, while the + contract held the same sets as bare type unions. Seventeen strings matched by + hand across a package boundary, with nothing checking them. They did all still + agree; the sweep that found them (#3786) verified that verbatim before changing + anything. + + Agreeing is not the same as being held, and both directions of drift are quiet: + + - a value the **column** accepts and the contract omits is invisible to every + consumer typed against the contract — the row exists and nothing can narrow it; + - a value the **contract** declares and the column rejects surfaces only at write + time, on whichever tenant first reaches that transition. + + An audit vocabulary is a bad place for either. So the contract now publishes the + lists as values — `APPROVAL_STATUSES` and `APPROVAL_ACTION_KINDS` — with + `ApprovalStatus` / `ApprovalActionKind` derived from them via + `(typeof X)[number]`, and the two columns spread the constants. The per-entry + rationale (which action kinds move the flow, which are thread-only, why + `returned` differs from `recalled`) moved onto the constants, where the values + live. + + **New exports, no behaviour change.** The emitted option lists are byte-identical + — verified against the built artifact before and after. Existing imports of the + two types are unaffected; the types resolve to the same unions. + + `approval-vocabularies.test.ts` pins the qualifier that derivation alone cannot: + the columns agree with the contract _while the spread is there_, and the test + fails if either is re-inlined as a literal that has drifted. It also guards the + guard (an unresolvable import would compare two empty lists and pass) and asserts + the two vocabularies stay distinct, since a copy-paste pointing one column at the + other constant would satisfy "derived from the contract" while being the wrong + vocabulary entirely. + + Verified by mutation in both directions: adding a value to `APPROVAL_STATUSES` + propagates into the built `sys_approval_request.status` options (the derivation + is live, not a stale build), and re-inlining a drifted literal fails + `sys_approval_request.status offers exactly the contract statuses, in order`. + +- 8d895ff: feat(spec,objectql,rest): publish the audit-provenance and import-coercion vocabularies (#3786, #4173) + + Two more hand-copied lists retired the same way, each replaced by one spec + export and derivation at every consumer. + + **`AUDIT_PROVENANCE_FIELDS`** (`@objectstack/spec/data`, with the + `AuditProvenanceField` type) — the four columns `applySystemFields` injects on + every audit-tracked object: `created_at`, `created_by`, `updated_at`, + `updated_by`. That four-name list existed in at least four copies across two + repos: the registry's injection if-chain, the rule-validator's `preserveAudit` + allowlist ("Kept in sync with the registry's auto-injected audit fields" — by + nothing), and two objectui render surfaces. Now: + + - the registry's injection is table-driven, keyed by the tuple with a + `satisfies Record` clause — a name added to the spec + without a column definition (or vice versa) is a compile error, the + `APPROVER_VALUE_BINDINGS` discipline; + - the rule-validator's `AUDIT_TIMELINE_FIELDS` derives from the same tuple; + - `FIELD_GROUP_SYSTEM_FIELDS`' audit prefix derives from it too — one + declaration even inside the file that hosts both; + - objectui's `AUDIT_FIELD_BY_ROLE` already pins itself by subset assertion and + can import the tuple directly once this release is published. + + Injection behaviour is byte-identical — a conformance test pins every injected + column's shape against the pre-refactor definitions. + + **`IMPORT_BOOLEAN_TRUE_TOKENS` / `IMPORT_BOOLEAN_FALSE_TOKENS` / + `IMPORT_REFERENCE_TYPES`** (`@objectstack/spec/data`) — the `/import` coercion + vocabulary #4173 asked for. The server's `import-coerce.ts` now derives its + `BOOL_TRUE` / `BOOL_FALSE` / `REFERENCE_TYPES` from these instead of owning + them privately, and objectui's Import Wizard preview — which re-checks the same + contract client-side so a cell is flagged red exactly when the server would + reject it — can retire its pinned-inventory mirror once this release is + published (the retirement path is written in that file's own header). + `IMPORT_REFERENCE_TYPES` ships with the legacy `'reference'` spelling included, + retiring the `+ 'reference'` literal both ends carried separately. The tables' + own discipline is tested: sets disjoint, every token pre-normalized + (lower-case, trimmed), and the Chinese / check-mark spreadsheet-reality tokens + pinned by name. + + No behaviour change anywhere: every derived value is byte-identical to the + literal it replaces. + +- f6472d7: feat(spec): the unknown-authoring-key lint covers every metadata collection, not just objects (#3786) + + #4148 introduced the lint for `object` and `field` — the two surfaces #4120 + caught real drift on. Those two were a sample, not the population: of the + authorable metadata types, only a handful are `.strict()` (`flow` / `permission` + / `position` / `tool` from #4001 Tier-A, joined mid-review by `app` via #4165). + Every other type strips an + undeclared key exactly the way `field` did — an author who misspells a key on a + `page`, an `agent` or a `dashboard` got the same parse-clean-value-gone silence, + with no lint watching. + + `lintUnknownAuthoringKeys` now walks **every metadata collection** — 16 today: + object, page, dashboard, report, dataset, action, job, agent, skill, hook, + mapping, datasource, view, email_template, doc, book — and its coverage is + **derived, not listed**: which collections exist comes from `PLURAL_TO_SINGULAR` + (the same boundary map the normalizer uses), which schema judges each comes from + the canonical type→Zod registry, and whether linting is even meaningful is read + off each schema's own unknown-key posture. A third hand-written "types the lint + covers" list would have been the #3786 shape all over again, inside the tool + built to end it. + + The posture rules keep the lint from ever disagreeing with the parse: + + - **strip** (zod default) → lint: the parse drops unknown keys silently, and + that silence is what gets reported. + - **strict** → skip: the parse already rejects loudly with the schema's own + tombstone guidance; a second, possibly disagreeing voice helps nobody. This + bucket GROWS as #4001 tiers graduate schemas — `app` graduated (#4165) while + this change was in review, and the derivation adapted without an edit. + - **passthrough** → skip: unknown keys survive the parse, nothing is dropped. + - **unions** (`view`) → the union of member keys; lintable only when a member + strips and none passes unknowns through. + + `defineStack`, `os validate` and `os build` pick the wider coverage up with no + code change of their own. Verified against the three first-party example apps + (28 pages, 29 flows, 11 actions and friends in the showcase): all clean, zero + false positives. Verified by mutation: dropping union handling, inverting the + strict filter, and skipping a collection each turn the tests red. + + New root/kernel exports: `listLintableAuthoringCollections` (+ + `LintableAuthoringCollection`) — the derived coverage as data, so tooling can + report what the evidence base for the #4001 strict tiers actually spans. + + One import-site change: `lintUnknownAuthoringKeys` moved from the `/data` + subpath to the package root and `/kernel` (`@objectstack/spec` root import is + unchanged and remains the canonical site). Covering every type means importing + every schema, and `/data` is consumed by frontend bundles — the walker moving + out keeps that chunk from inheriting the whole schema universe. If you imported + it from `@objectstack/spec/data`, import from `@objectstack/spec` instead. The + comparator, guidance tables and finding types stay in `/data`, unchanged. + +- 62a789b: Reconcile the remaining flat builtins' declared config against what their + executors read (#4045 — the CRUD / screen / map step, after notify / http / + connector in #4210). + + **Six executor-derived Zod contracts.** `GetRecordConfigSchema`, + `CreateRecordConfigSchema`, `UpdateRecordConfigSchema`, + `DeleteRecordConfigSchema`, `ScreenConfigSchema` (+ `ScreenFieldConfigSchema`) + and `MapConfigSchema` in `automation/builtin-node-config.zod.ts`, each written + by reading the executor rather than transcribing the descriptor literal, so the + new bidirectional ledger test is evidence rather than a tautology. Contract + exports only — nothing parses with them yet (#4045 step 3b, gated on the #4059 + warning data). + + **Seven capabilities the executors honour are now authorable.** Each was read + by the executor and offered by no form — online or offline — so it was reachable + only by hand-written metadata: + + - `get_record.fields` — the query projection, passed straight into + `find`/`findOne`; + - `screen.recordId` — the record `mode: 'edit'` opens; the form declared the + edit mode while offering no way to name its target; + - `screen.fields[].options` / `defaultValue` / `placeholder` — all three + forwarded into the ScreenSpec the client renders, so a select field's choices + could not be authored in Studio at all. Same nested repeater position as the + `visibleWhen` gap #3528 was filed for; + - `map.indexVariable` and `map.input` — the index binding and the per-item + subflow params. + + **`map`'s undeclared `flow` alias graduates to the conversion layer.** The + executor carried `cfg.flowName ?? cfg.flow` for a spelling no schema ever + described — the `notify.source` shape (Prime Directive #12). The bare fallback + is deleted and `flow-node-map-flow-alias` (protocol 17, retires at 18) renames + it at load, including the `AutomationEngine.registerFlow` rehydration seam. + + **`assignment` is pinned as deliberately un-reconcilable**, with the reason on + record: with no `assignments` wrapper its top-level config keys ARE the author's + variable names, so no fixed key set can describe it and a catchall Zod would + reconcile vacuously. What the ledger pins instead is that the form offers + exactly the canonical `assignments` map and that the map stays open. + + With this, every builtin that publishes a `configSchema` is reconciled against + its executor, and the ones that publish none each have a recorded reason. + +- 789ad63: fix(spec,rest): the batch-size cap is enforced now, and each bulk endpoint has one Zod source (#3939) + + `max 200` was declared in four places and enforced in one. + + `batch.zod.ts` put `.min(1).max(200)` on `BatchUpdateRequestSchema`, + `UpdateManyRequestSchema` and `DeleteManyRequestSchema`, and the docs repeated + it — but no per-object bulk route validated against those schemas, so + `createMany` / `updateMany` / `deleteMany` / `/data/:object/batch` all accepted + an unbounded list. The only route that capped anything was the cross-object + `/batch`, and it checked the _configured_ `maxBatchSize` rather than the + hardcoded 200 — so even the one enforcement point disagreed with the schema. + + That stopped being cosmetic with #3897, which made `deleteMany` delete per id by + primary key (so `deleteBehavior` cascades run and every row gets its own + result). A 10k-id body is now 10k sequential engine round-trips inside a single + request, where before it was one statement that mostly failed anyway. + + **The cap moved to the routes, and the schemas gave it up.** Batch size is + deployment policy — `RestServerConfig.batch.maxBatchSize`, 1..1000, default 200 + — so a hardcoded bound in the spec could only ever be a second, wrong answer + (a deployment raising the limit to 500 would still have been refused at 200). + All five bulk routes now call one `enforceBatchSize` helper with the configured + value and answer with one envelope: + + ```json + { + "error": "Batch too large: 500 records (max 200)", + "code": "BATCH_TOO_LARGE", + "count": 500, + "max": 200, + "object": "account" + } + ``` + + The cross-object route is included: it used to answer with a bare `error` string + and no `code` for a client to key on. + + **One Zod source per bulk endpoint (Prime Directive #7).** Each of these + endpoints had _two_ schemas, and they had already drifted into disagreeing about + more than counts: `UpdateManyRequestSchema` described its rows with + `BatchRecordSchema`, whose `id` and `data` are optional because the generic + `/batch` route serves create (no id) and delete (no data) through the same + shape — so the declared contract accepted `{}` rows that `updateManyData`, which + reads `record.id` and `record.data` unconditionally, could never process. The + enforced shape lived in the _other_ copy, in `protocol.zod.ts`. + + The wire body is now the single source (`UpdateManyRequestSchema` / + `DeleteManyRequestSchema`, with the new `UpdateManyRecordSchema` for a row), and + the protocol schemas are that plus the `object` the route takes from the URL + path (#3933) — `UpdateManyRequestSchema.extend({ object })`. The derivation runs + that direction because `protocol.zod` already imports `batch.zod`; the reverse + would be a cycle. + + **Behaviour changes.** + + - A bulk request over the configured cap is `400 BATCH_TOO_LARGE` instead of + being executed. Deployments that were quietly relying on unbounded batches + should raise `batch.maxBatchSize` (up to 1000) rather than discover the cap in + production. + - `.min(1)` is gone with `.max(200)`: an empty batch is a no-op returning + `total: 0`, which is what these routes already did, rather than a validation + error the schema claimed but nothing raised. + - `UpdateManyRequest` now types (and validates) `records` as + `{ id: string; data: Record }[]`. Callers already had to send + that — the route has validated the strict shape since #3933 — but the declared + type was looser. + - New export: `UpdateManyRecordSchema` / `UpdateManyRecord`. + +- 2af1988: fix(formula,spec,core): the RLS write-side `check` evaluator honours calendar-day upper bounds (ADR-0053 D-D) + + `@objectstack/formula`'s `matchesFilterCondition` — the evaluator behind RLS + write-side `check` policies (ADR-0058 D4) — compared a bare `YYYY-MM-DD` `$lte` + bound literally. On a `datetime` post-image that meant a policy of the shape + `{ signed_on: { $lte: '{today}' } }` **denied every write made after 00:00**: + the write-side twin of the read-side data loss #3777 fixed, and the last of the + platform's filter backends that disagreed about what a bare day means as a + bound. + + `$lte` and a `$between` max now evaluate half-open against the next calendar + day, matching the SQL compiler, the memory and mongo drivers, and the analytics + preview evaluator. Unchanged, per the same semantics table: full-ISO bounds keep + exact-instant semantics, `$gte`/`$gt`/`$lt` keep their midnight anchoring, and a + plain `YYYY-MM-DD` value compares identically (string ordering makes the two + forms equivalent). The evaluator stays fail-closed on a null bound. + + **Where the rule now lives.** `nextUtcCalendarDay` moved from + `@objectstack/core` to `@objectstack/spec/data` — beside `date-macros.zod.ts`, + whose vocabulary it interprets. `formula` cannot depend on `core`, and a second + copy of the rule is exactly the divergence #3777 catalogued; `spec` is the one + package all six consumers already depend on, so this adds no dependency edge. + + No import changes are required: `@objectstack/core` re-exports the symbol, so + existing `import { nextUtcCalendarDay } from '@objectstack/core'` keeps working. + New code should prefer `@objectstack/spec/data`. + +- 41dcda3: fix(spec,runtime,service-automation): `IAutomationService` declares the connector registry it already serves (#4127) + + The fourth and last of the dispatcher call sites #4127 found calling a method its + contract never declared. The first three shipped in #4143; this one was held back + because the fix is a **type move**, not a type addition — `ConnectorDescriptor` + was declared in `@objectstack/service-automation`'s engine, which is one + _implementation_ of `IAutomationService`. A contract cannot name a type that + lives inside its own implementation, so `getConnectorDescriptors` could not be + declared at all until the type had a home in the spec. + + **`IAutomationService` += `getConnectorDescriptors?()`.** It is the sibling of + `getActionDescriptors`, which the contract has declared since ADR-0018: the two + fill the flow designer's `connector_action` node together — node vocabulary from + one, the connector → action → input pickers from the other. Only one of them was + written down. `GET /api/v1/automation/connectors` has served the other since + ADR-0022 by probing for the method and then re-typing its own result as `any` to + filter on `?type=`, which is a filter on a field the type system did not know + existed — one typo from silently matching nothing and answering an empty + registry, which is also what this route legitimately returns when the method is + absent, so the failure had no distinguishable symptom. + + Optional for the same reason `getActionDescriptors` is: a connector registry is a + capability of the flow-engine implementation, not a property of every automation + slot. A script-runner filling the slot has no connectors to describe, and the + route answers an empty registry rather than a 404 — the `handlerReady` posture + does not apply, since the slot is serveable and only this capability is absent. + + **`ConnectorDescriptor` / `ConnectorActionDescriptor` / `ConnectorOrigin` / + `ConnectorState` move to `@objectstack/spec/integration`**, beside the ADR-0097 + provider contract, for the reason that file already states about itself: they are + pure types, so a connector plugin — or a designer client, or the dispatcher — + speaks about registered connectors depending only on the spec, with no runtime + coupling to the engine. `ConnectorOrigin` is ADR-0097 §4 vocabulary and + `ConnectorState` is #3017 vocabulary; neither was ever engine-private in meaning, + only in location. + + Nothing is renamed and no shape changes. `@objectstack/service-automation` + imports the four back and re-exports them from its index — the same names, from + the same entry point — so every existing importer compiles unchanged. + `ConnectorState` joins that re-export, which it should have been in all along: it + is a required field of the descriptor the index has always exported. + + **The test fixture had already drifted, which is the concrete cost.** The + dispatcher's connector mock declared `{ name, label, type, actions }` and omitted + `origin` and `state` — both **required** on `ConnectorDescriptor`, and both the + fields a designer reads to tell a live declarative instance from a plugin one + (ADR-0097 §4), or a dispatchable connector from a degraded one that is listed + honestly rather than hidden (#3017). Nothing caught it, because an undeclared + return type cannot be checked against. The fixture is typed now, so it cannot + drift again, and a new test pins that `origin` / `state` / `degradedReason` + survive the hop through the route rather than only `name` and `type`. + + Verified: `@objectstack/spec` **7089 tests / 272 files** (2 new contract tests), + `@objectstack/service-automation` **457 / 41**, `@objectstack/runtime` + **218 http-dispatcher tests** (1 new), `tsc --noEmit`, `pnpm lint`, the liveness + and empty-state gates, and the three generated-artifact gates — all clean. + +- a1a4140: feat(spec): `defineHook()` — authoring-time factory for lifecycle hooks (#4269) + + New public API, following the `defineDatasource` template: accepts input-shape + config (`Hook`), runs `HookSchema.parse`, returns the resolved shape + (`ResolvedHook`, defaults materialized). Exported from the package root and + from `@objectstack/spec/data`. + + Why it exists: the convention-scan authoring path + (`src/objects/.hook.ts`) never parsed at all, so the #4207 + alias/guidance errors were unreachable before deploy, constraint-level rules + (snake_case `name`, event names) went unchecked at authoring time, and the + scan-path artifact stayed in input shape while the `defineStack({ hooks })` + path shipped output shape. Wrapping the literal in `defineHook()` closes all + three gaps — a bad hook now hard-fails at import instead of degrading to a + bind-time skip + warning (the #4001 posture: silent no-ops fake completion). + + The factory is a pure parse: handler-deprecation advice stays in the binder + (`bindHooksToEngine`'s `warnLegacyHandler` option), one place only. Existing bare `: Hook` literals keep + working; re-parsing factory output at bind time is idempotent. + + Also fixes the two `UNKNOWN_KEY_GUIDANCE` prescriptions in `object.zod.ts` + (`workflows` / `hooks`) that referred authors to a `defineHook()` that did not + exist — the error message itself used to manufacture a second error; it now + names a real function and its import path. + +- 86a71d1: Discovery's "install this to enable" now names a package that exists (#4093 follow-up). + + Discovery tells a consumer two things about an absent capability: that it is absent, and what to do about it. The first has been carefully honest since #2462/#4000. The second was invented from the slot name. + + The dispatcher templated `Install a ${slot} plugin to enable` across twelve slots, and `metadata-protocol` carried a hand-written table in which **ten of fifteen entries named a package that does not exist** — `plugin-redis`, `plugin-bullmq`, `job-scheduler`, `plugin-notifications`, `plugin-storage`, `plugin-automation`, `ui-plugin`, plus `plugin-ai`, `plugin-search` and `plugin-workflow` for slots nothing implements at all. That value is also surfaced as discovery's `provider`. + + A remedy naming a package that cannot be installed is a dead end handed to someone at the exact moment they are trying to fix their stack — and an agent reading discovery cannot tell it apart from a package it should install. It is the same `declared ≠ enforced` failure this lineage has been closing, one level over: not "does the capability exist" but "is the fix real". + + `CORE_SERVICE_PROVIDER` and `serviceUnavailableMessage()` in `@objectstack/spec/system` are now the one place that sentence is written, and both discovery builders read them, so the two hosts cannot tell a consumer to install different things (the drift #4089 and #4130 closed for the `metadata` and `data` entries). Entries were verified against what actually calls `registerService` for each slot rather than against name similarity — which is how `notification` turned out to be filled by `@objectstack/service-messaging`, the one slot whose package shares no word with its name. + + Four slots — `ai`, `search`, `workflow`, `graphql` — have no implementation anywhere, so they now say so instead of naming a plausible package. `ui` keeps the fuller sentence it got in #4146 (`/ui` is served by the `protocol` service; nothing registers the `ui` slot), and that sentence now reaches both builders instead of one. + + `scripts/check-service-providers.mjs` (wired into the lint workflow as `check:service-providers`) fails CI when a named package is not a real workspace package, or when a `CoreServiceName` slot has no entry — so a rename or a deletion cannot leave a stale instruction behind. + + FROM → TO: `services..message` and `services..provider` change text for most unavailable slots. Anything matching on the old `Install a plugin to enable` wording should match on `status: 'unavailable'` instead — the status field is the contract; the message is prose for humans and agents. + +- d5c75e2: fix(spec,runtime,service-i18n): the dispatcher domains and their service contracts describe the same surface (#4127) + + #4087 retired a `/storage` bridge that called `upload(key, data, options?)` as + `upload(file, { request })` — a shape no implementation has. Sweeping the other + dispatcher domains against `packages/spec/src/contracts/*` found the mirror-image + gap in three places: the call site and the implementation agreed, and the + **contract** was the thing that had never been written down. Each one was worked + around at the call site with `typeof x.foo === 'function'` — a duck-type is what + "the contract does not cover this" looks like when nobody fixes the contract. + + Fixed at the contract, per Prime Directive #12. + + **`INotificationService` — the inbox half.** `listInbox` / `markRead` / + `markAllRead` now exist, with `InboxQuery` / `InboxNotification` / + `InboxListResult` / `MarkReadResult`. Three SDK-expressed routes + (`notifications.list` / `.markRead` / `.markAllRead`) have rested on them all + along, implemented by `service-messaging`, while this contract described only + `send`. The cost was not theoretical: the dev notification stub implements + exactly `send` and `sendBatch` **because it followed the contract**, so the one + implementation written to spec was the one the dispatcher had to duck-type past. + + They are optional, and the probe stays: an inbox needs a durable store, and a + send-only provider (SMTP, Twilio, a Slack webhook) fills the slot legitimately + without one. `handlerReady` cannot express that — the slot is serveable, one + capability of it is absent. The `/notifications` domain now takes + `INotificationService` instead of `as any`, and each write route probes its own + method rather than riding the entry `listInbox` check (they are separately + optional, so "has an inbox to read" never implied "has read-state to write"). + + **`II18nService.getFieldLabels`.** Both serving surfaces — the dispatcher's + `/i18n/labels/:object/:locale` and service-i18n's own mount — probed for it and + both documented it as "optional on `II18nService`", which was not true. It is + now. service-i18n's probe loses two casts with it (one through + `Record`, one re-declaring the signature inline). + + **`IAutomationService.getFlowRuntimeStates`** + the `FlowRuntimeState` type. + `GET /automation/_status` (and the CLI boot summary, and the + `kernel:bootstrapped` audit) already called it while the contract stopped at + `listFlows(): string[]`. The dispatcher's inline cast declared it as + `{ name, enabled, bound }` — a third copy of the shape and a narrower one than + the engine returns, dropping the `status` / `triggerType` / `object` fields that + say WHY a flow is unbound. + + Two runtime fixes fell out of the same sweep: + + - **`POST /automation/trigger/:name` now builds a real `AutomationContext`.** + It passed the raw HTTP body to `execute(name, body)`, so the + `{ recordId, objectName, params }` translation never ran and — the sharper + half — no caller identity was forwarded. A flow's default `runAs` is `'user'`, + and a `runAs:'user'` run whose trigger resolved no user has its data + operations REFUSED (#3760, fail-closed), so `client.automation.trigger()` + could not run a data-touching flow at all while `POST /:name/trigger` could. + service-automation's own comment claims "most trigger surfaces (REST action / + trigger endpoint) already resolve the full envelope"; for this endpoint it was + not true. Both routes share one context builder now. + - **The dead `automationService.trigger(...)` probe is gone.** Nothing in the + repo has ever implemented `trigger` on the automation slot and the contract + never declared it, so the branch was unreachable on every deployment and its + `execute` "fallback" was the route. Declaring `trigger?` would have blessed a + second name for `execute`; the dead branch is deleted instead. + + No migration. Every added contract member is optional, so existing + implementations stay valid; the two runtime fixes only make routes that were + failing or degraded behave like their working twins. + +- 03d26f7: fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842) + + `HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it + straight into the field `ApiErrorSchema` reserves for a semantic string, so + `error.code` came back as `400`/`403`/`503` — a number, duplicating the response + status and occupying the one slot a caller is meant to branch on. The real code + then had to go somewhere else, and did, three somewhere-elses: `details.code` + (auth gate, permission denial, anonymous deny), `details.type` + (project-membership gate), and `error.type` (`routeNotFound`). Four sites, three + parking spots, because the declared one was full. + + **FROM → TO on the wire.** A dispatcher error body + + ```json + { + "success": false, + "error": { + "message": "…", + "code": 403, + "details": { "code": "PERMISSION_DENIED" } + } + } + ``` + + is now + + ```json + { + "success": false, + "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } + } + ``` + + | Reading | Was | Now | + | ------------- | ---------------------------------------------------------- | ------------------------------------------------- | + | semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` | + | HTTP status | `error.code` | `error.httpStatus` (or the response status) | + | context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) | + + **One-line fix for a direct reader:** replace `body.error.details?.code ?? +body.error.type` with `body.error.code`, and `body.error.code` with + `body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient` + already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still + reads the old shape, so a client newer than its server is unaffected. + + Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`, + `ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, + `VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not + rename anything. Reconciling the repo's two code vocabularies is #3841, and this + leaves it exactly one map and one enum to sweep instead of four parking spots. + + A branch with no code of its own is served one derived from the status, via the + single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus` + in `@objectstack/spec/api` (`403` → `permission_denied`, `503` → + `service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code` + is required; drawing it from `StandardErrorCode` keeps a derived code a + catalogued one rather than an invented string. + + **Spec changes:** + + - `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is + `EnhancedApiErrorSchema.httpStatus`. Additive. + - `StandardErrorCode` gains `method_not_allowed` and `precondition_required`, + the two statuses the runtime returns that the enum could not name. Additive. + - **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string + spellings of HTTP statuses, for matching against the numeric `error.code`). It + is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' | +'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum + declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO + `DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read + `error.httpStatus`. TypeScript flags every call site. + - **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()` + (was `z.number().int()`), `error.type` is **removed** (folded into `code`), and + `error.httpStatus` / `error.details` are declared. This schema is what + legitimised the deviation — it declared the opposite of `ApiErrorSchema` for + the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO + `{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`. + + **Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s + `errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405. + `errorResponseBase` previously discarded a thrown error's `.code` outright — it + had nowhere to put it — so the two exits of one surface disagreed about what a + caller would see; they now agree. Every body on this surface is built by one + helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by + `error-envelope.conformance.test.ts`: each branch driven and parsed against the + schema imported from `packages/spec`, plus a source scan so a new branch cannot + quietly reintroduce a numeric `code` or a `type`-as-code sibling. + + This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be + deleted rather than updated once the dispatcher was fixed. + +- ed77493: fix(objectql,spec): `filter` folds to `where` on EVERY engine method, and `top`/`limit` joins the #3795 slot table (#4346) + + The `filter` → `where` fold that #3795 settled at the protocol layer existed + at the **engine** layer in exactly one of six methods. `ObjectQL.find()` + folded it; `findOne`/`count`/`update`/`delete`/`aggregate` passed the option + bag through with `ast.where === undefined`, which every driver reads as "no + predicate" — so a caller filtering with `{ filter }` silently matched EVERY + row: + + | call | before | after | + | ------------------------------------ | ----------------------- | -------------- | + | `findOne({filter: {status:'done'}})` | first row of the table | a matching row | + | `count({filter})` | whole-table count | matching count | + | `update(data, {filter, multi:true})` | **every row rewritten** | matching rows | + | `delete({filter, multi:true})` | **table emptied** | matching rows | + | `aggregate({filter, …})` | aggregated all rows | matching rows | + + This was reachable, not theoretical: the deprecated + `DataEngine{Query,Update,Delete,Count,Aggregate}OptionsSchema` contracts all + declare `filter`, `ScopedContext`/`ObjectRepository` (the cross-object API + handed to L2 hook bodies) forwards its argument verbatim, and the spec's own + hook documentation taught the broken call + (`users.findOne({ filter: { role: 'admin' } })` — now corrected to `where`). + + Every engine entry point now folds through the spec's own #3795 machinery + (`RPC_QUERY_ALIAS_SLOTS` + `foldQueryAliasSlots`) instead of `find`'s + hand-rolled copy, under the #4181 rule: an alias alone folds, redundant + identical spellings collapse, DIFFERENT values for one slot throw + ("Send exactly one") instead of silently picking a winner, and an explicit + `null` alias is a withdrawal. + + **The sixth pair.** `top` → `limit` — the pair the #3795 scope note excluded + as "the OData layer" — joins `RPC_QUERY_ALIAS_SLOTS`. The protocol normalizer + folded it BACKWARDS (`options.limit = Number(options.top)` — the alias + overwrote the canonical key) while `engine.find` folded it canonical-wins, so + `{top: 1, limit: 3}` answered 1 over HTTP and 3 through a direct engine call. + All three readers (wire normalizer, RPC schema parse, engine) now resolve the + pair identically: `top` alone still limits, a conflicting `{top, limit}` is + refused. + + Behavior change to note: option bags that previously smuggled conflicting + spellings (`{where: X, filter: Y}`, `{top: 1, limit: 3}`) are now refused + loudly on every path instead of silently resolving differently per layer. + Pinned per method, write paths included — a regression here is silent and + destructive, and the class went unnoticed precisely because `find` was the + only method anyone thought to check. + +- dc530b4: **`envelopeViolations` — the conformance check `BaseResponseSchema` cannot express.** + + Every conformance suite from the #3843 line leads with + `BaseResponseSchema.safeParse(body)`, under a comment claiming it is "the contract + itself, imported — not a restatement of it". That overclaimed, and the gap is + demonstrable: + + ```ts + BaseResponseSchema.safeParse({ success: true }); // passes + BaseResponseSchema.safeParse({ success: true, data: link, link }); // passes + ``` + + The schema declares no `data` — each response type adds its own via + `.extend({ data })` — and a plain `z.object` strips unknown keys rather than + rejecting them. So it catches the one drift it was added for (a missing or + non-boolean `success`, the flag `unwrapResponse` keys on) and nothing else. The + second body above is exactly the duplicate-payload drift `/share-links` shipped + until #4038 / #4049 removed it, and `safeParse` passed it the whole time. + + `envelopeViolations(body)` returns every departure from the declared envelope as + readable reasons, empty when conformant: + + - `success` must be a **boolean** + - a success body must carry `data` (`undefined` only — `null`, `[]`, `{}`, `0` + and `''` are payloads, not absences) + - a failure body must carry `error` with a string `code` and `message` — the + nested form, not the pre-#3675 bare string + - no top-level key outside `success` / `data` / `error` / `meta`, which is the + general form of the duplicate-payload drift + + It deliberately does **not** check the shape of `data`: that is each route's own + payload schema, and conflating the two is what let `SettingsNamespacePayload` + describe a whole body before #3843 and only `data` after it. + + The ten conformance suites now assert it beside `safeParse`, and their comments + say what each of the two actually proves. Reintroducing the `/share-links` + duplicate key is caught by the new assertion and still passes the old one — which + is the demonstration that the pairing is the point. + + Placed in `contract.zod.ts` beside the schema it completes, alongside the other + plain predicates `spec/api` already exports (`standardErrorCodeForHttpStatus`, + `readServiceSelfInfo`). No new package. + +- e59786e: fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) + + A recursive Zod schema needs an explicit annotation to break its circular + inference, and five of them took the cheapest one available: + + ```ts + export const NavigationItemSchema: z.ZodType = z.lazy(() => …); + export type NavigationItem = z.infer; // → any + ``` + + It compiles, it validates correctly at runtime, and it silently throws the type + away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all + `any` on the published surface, plus `FieldNodeSchema` — which had no exported + type alias yet, so `z.infer` was `any` and + `QueryAST['fields']` with it. + + That is worse than a missing export. #4115 tells every consumer that a local + declaration under a spec export's name must be replaced by a binding to the + spec — and for these, obeying it **replaced a precise type with `any`**. + objectui's `NavigationItem` is a 118-line documented interface (`recordId` + template variables, `requiresObject` / `requiresService` capability gates, + `filters` precedence); every key of it exists in the spec's version, so by every + available signal it read as a redundant fork safe to delete. Deleting it swapped + a fully-typed interface for `any`, with no compile error anywhere to say so. + + It is hard to catch by inspection because `any` is mutually assignable with + everything, so the natural "are these the same type?" check answers _yes_ in both + directions and recommends precisely the wrong action. Same failure family as + #4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything + reads as agreement. + + **Now annotated with the real type**, using the pattern `QueryAST` already + follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive + knot in the type, so the keys stay derived from the schema instead of being + hand-maintained beside it: + + ```ts + const BaseXSchema = z.object({ …every non-recursive key }); + export type X = z.infer & { children?: X[] }; + export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + children: z.array(XSchema).optional(), + })); + ``` + + `z.infer` now resolves to the type it should always have been: `NavigationItem` + is the nine-branch discriminated union, `FormField` the 30-key form-field + contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into + `visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` + the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime + validation is unchanged: every schema parses exactly what it parsed before. + + **What the types immediately caught**, none of it visible while they were `any`: + + - `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has + never declared. It worked only because objectui's `NavigationRenderer` still + falls back to that legacy alias. Fixed at the producer per Prime Directive + #12: the canonical key is `expanded`. + - The MongoDB driver built its projection with `projection[field] = 1` over + `query.fields`, so a relationship `FieldNode` would have keyed the projection + on `"[object Object]"`. It now reads the node's field name. + - `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated + with the PARSED `App` / `NavigationContribution` types but omitted + `.default()`ed keys (`expanded`, `target`), as did the form fields + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the + default it was relying on, matching what the surrounding literals already do + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. + + **Gated, not just fixed** (`check:exported-any`, wired into the required + `TypeScript Type Check` job). `api-surface.json` records that an export _exists_ + and never what it _resolves to_, which is how these survived a whole major with + every gate green. The new scan reads the built `.d.ts` a consumer's import + actually resolves to and fails on any exported type that resolves to `any` — or + any exported schema whose output is `any`, the root cause, and the only reason + `FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and + currently empty. It self-tests against the real zod first, so if the internals it + reads are ever renamed the gate fails loudly instead of quietly passing + everything forever. + +- b07d829: feat(automation,spec): flow executors `parse()` their config, and undeclared config keys reject at registration (#4277) + + The #4045 reconciliation left every flat builtin with a Zod config contract that + nothing enforced, and #4059 left `registerFlow` warning about undeclared keys it + could not yet safely reject. #4277 installs both halves of the enforcement: + + **1. Executors parse their config (execute time).** The 12 contract-carrying + builtins — `get_record` / `create_record` / `update_record` / `delete_record`, + `screen`, `map`, `notify`, `http`, `loop` / `parallel` / `try_catch` — now run + `node.config` through their Zod contract before executing + (`service-automation/builtin/parse-config.ts`). A type or missing-`required` + violation refuses the node as a **guard** (`errorClass: 'guard'`, not routable + via `fault` edges — config is metadata; re-running changes nothing), naming + every violated path. `{token}` templates stay legal: string-typed slots parse + the raw template, and `http` — whose executor reads the interpolated config — + parses POST-interpolation, where a whole-token template has already resolved to + its value's real type. Exemption: a legacy flat-graph `loop` (no `config.body`) + predates the ADR-0031 construct and is not parsed. + + **2. Undeclared config keys are rejected at `registerFlow` (registration + time).** The #4059 warning is now an error: a config key the node type's + descriptor `configSchema` does not declare fails registration, with the exact + path, the declared key set, a did-you-mean, and — for keys with documented + history (`screen.visibleIf`, `create_record`/`update_record.fieldValues`) — a + per-key tombstone (the `UNKNOWN_KEY_GUIDANCE` pattern). Unchanged exemptions: + `assignment` is exempt wholesale (its top-level keys ARE the author's variable + names), schemaless types (`decision`/`script`/`wait`/`subflow`/ + `connector_action`) declare nothing so nothing can be undeclared, and keyValue + maps stop the walk (their keys are author data). Every `registerFlow` call site + already try/catches per flow, so a bad stored flow is skipped loudly at boot, + never a crashed kernel. + + **Contract fix folded in:** `LoopConfigSchema.collection` is now + `z.union([z.string().min(1), z.array(z.unknown())])` — the executor has always + accepted an inline array (shared resolve logic with `map.collection`, which + already declared the union), so the string-only declaration under-declared what + it reads. + + **Migration.** If a flow stops registering: the error names the undeclared key + and its path — rename it to the declared key it meant (`visibleIf` → + `visibleWhen`, `fieldValues` → `fields`), or delete it (an undeclared key was + never read, so removing it changes no behavior). If an executor of yours + genuinely reads the key, declare it on the node type's descriptor + `configSchema`. If a node starts refusing at run time: the refusal names each + violated path against the contract — fix the value's type or supply the missing + required key (e.g. `get_record` `limit` must be a number; `screen` + `fields[].options` entries are `{ value, label }` objects; `notify` requires + `recipients` + `title`). Retry-policy defaults now come from the contract: a + `try_catch` `retry` block that omits `retryDelayMs` gets the documented 1000ms + base delay where the executor historically used 0. + +- a47ac06: feat(spec,automation): graduate the seven flow-node config key aliases into the conversion layer — the `readAliasedConfig` shim retires with them (#3796) + + `FlowNodeSchema.config` is an unconstrained record, so the executors were the + only statement of which config key is canonical — and seven deprecated aliases + lived there as tolerance the spec never declared: one behind the + `readAliasedConfig` deprecation shim (warned, ledgered), six as open-coded + `??` fallbacks (no warning, no ledger, no retirement path). All seven now + graduate into the ADR-0087 D2 conversion layer as protocol-17 **live-window** + entries: a stored flow authored with an alias is rewritten to the canonical + key at load — `defineStack` / `validate` / `lint` and the + `AutomationEngine.registerFlow` rehydration seam alike — with a structured + `ConversionNotice` per rewrite, and the executors read the canonical keys + only. The shim (`service-automation/src/builtin/config-aliases.ts`) is empty + and deleted. + + FROM → TO (per node type; conversion entry in parentheses): + + - `get_record`/`create_record`/`update_record`/`delete_record`: + `config.object` → `config.objectName` (`flow-node-crud-object-alias`) + - `notify`: `config.to` → `config.recipients`, `config.subject` → + `config.title`, `config.body` → `config.message`, `config.url` → + `config.actionUrl` (`flow-node-notify-config-aliases`) + - `script`: `config.functionName` → `config.function`, `config.input` → + `config.inputs` (`flow-node-script-config-aliases`) + + One-line fix: rename the key in your flow source — values are unchanged; `os +migrate meta --from 16` rewrites all seven mechanically. Until then nothing + breaks: the protocol-17 loader accepts and converts the old shape (window + retires in 18). + + `actionUrl` (not `url`) is the deliberate canonical of its pair, resolving a + contradiction where the notify descriptor documented `url` as canonical while + the executor, tests, and examples preferred `actionUrl`: the whole downstream + chain already uses that name (`sys_notification.action_url`, the + channel-dispatch contract, the REST notification read model), and `url` + elsewhere in the platform means "HTTP endpoint to call" (`http` node, + webhooks) — a different concept from this in-app click-through target. The + executor precedence already put `actionUrl` first, so the choice is + behaviour-preserving; the `notify` descriptor's `configSchema` now documents + `actionUrl`. + + Callers that hand a node config **directly** to an executor (bypassing + `registerFlow`) no longer get alias resolution — build the config with the + canonical keys. + +- e4c61a7: Validate the expression slots a flow node's `configSchema` declares (#4027). + + A node type's designer `configSchema` and the keys its validators traverse were + two unreconciled lists. Both the engine's `registerFlow` pass and the author-time + `objectstack validate` pass hardcoded `config.condition` / `edge.condition` and + assumed every other node string was a `{var}` template — so a declared expression + property outside that hardcoded set was validated by nobody. + + That is how #3528 shipped. `screen.fields[].visibleWhen` has been on the `screen` + descriptor since #3304, typed `xExpression: 'expression'` (bare CEL) and offered + to authors in Studio, but no validator traversed it. An app authored the + predicate in the _other_ dialect — `'{createOpportunity} == true'` — and it passed + `tsc`, `objectstack validate` and registration in silence. Because `required` _is_ + enforced, a field the author had made conditional rendered unconditionally and + blocked Submit on an input the user was never shown: the run paused forever and no + resume was ever issued. + + Now: + + - **`FLOW_NODE_EXPRESSION_PATHS`** (`@objectstack/spec`) is the declared ledger of + expression-bearing node config paths, each recording the dialect it takes. + - **Both validators read it.** A malformed `visibleWhen` is a located, quoted + error at `registerFlow` _and_ at `objectstack validate` — `node 'screen_1' +(screen) screen field visibleWhen at config.fields[1].visibleWhen`. + - **A reconciliation ratchet** derives the expression properties from the live + descriptors and fails CI in both directions: a new `xExpression` property with + no ledger entry, or a stale entry no descriptor declares. It walks every + registered builtin, not just `screen`. + + Dialects are recorded rather than assumed because there are three, and two of them + disagree about braces: bare CEL (`{…}` is the #1491 brace-trap), single-brace + `{var}` flow interpolation (`{…}` is correct), and the ADR-0032 §3 double-brace + text template. Only bare-CEL slots are checked — `loop.collection` and + `map.collection` are recorded as `flow-template` and deliberately left alone, + since no validator implements their dialect and checking them under either of the + other two would reject every currently-valid flow. + + `ActionDescriptor.configSchema`'s TSDoc no longer claims `registerFlow()` + validates `config` against it. It never did: `FlowNodeSchema.config` is + `z.record(z.unknown())`, so types, `required`, `enum` and unknown keys are still + unenforced. The doc now states exactly what is checked and what is designer-facing + only, so nothing relies on a guard that does not exist. + +- 081aa6f: feat(spec,service-automation): every flow run reports what it actually did — selected / acted / skipped (#4354) + + `success: true` never meant "it did its job". A scheduled sweep that selects + thirty records and writes none is, from outside, **identical** to one with + nothing to do: same green status, same empty output, same silence, same schedule + tomorrow. There was no signal anywhere that separated "nothing to do" from + "broken". + + That is not theoretical. #4347 left three hotcrm production flows completely + inert — the stalled-deal sweep found every stalled deal and nudged nobody, the + renewal sweep booked nothing, the campaign action enrolled no leads. They ran + daily, on time, green, for as long as they had existed, and were caught only by + adding tests that assert on records written. Automation is exactly the category + where nobody is watching: a UI bug files a ticket within the hour, a dead sweep + files nothing, and the longer it runs the more normal the silence looks. + + **Every terminal run now carries a `FlowRunSummary`** — on the + `AutomationResult`, on the run in `listRuns` / `getRun`, in the log, and in the + database: + + ``` + [automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30 + ``` + + - `selected` — records read by the run's data nodes + - `acted` — records created / updated / deleted, plus effects dispatched + (notifications delivered) + - `skipped` — node executions a closed gate prevented, one per loop iteration + whose conditional edge evaluated false + - `nodes[]` — per-node terminal status with `runs` / `failures` / `skipped` + - `gates[]` — which gates closed and how often, most-skipped first + + **The counts are declared, not sniffed.** Executors report + `NodeExecutionResult.metrics`, because only the node knows what its result + _means_: `update_record`'s is a row count on a bulk write and a record on a by-id + one, `delete_record`'s can be a boolean, `notify`'s is a delivery count. An + engine inferring from output shapes would be guessing, and a machine-readable + count that guesses is worse than none. A node that touches no records + (`decision`, `assignment`) reports nothing — absent is not `0`. + + **The gate is named.** A conditional out-edge that evaluates false now records a + `skipped` step tagged with the gate that closed. That event previously left no + trace at all, which is why #4347 was invisible: the flow selected every row and + the loop-body edge never opened. A skipped step is explicitly _not_ a run — the + ADR-0044 re-entry guard, per-node `runs`, and node status all exclude it, so a + new observability signal cannot change execution semantics. + + **Queryable, so it can be alerted on rather than noticed.** + `sys_automation_run` gains `selected_count` / `acted_count` / `skipped_count` + columns plus a `summary_json` breakdown: + + ```typescript + const suspect = await engine.find("sys_automation_run", { + where: { status: "completed", selected_count: { $gt: 0 }, acted_count: 0 }, + orderBy: [{ field: "started_at", order: "desc" }], + }); + ``` + + `selected > 0 && acted == 0` over consecutive runs is a near-perfect + broken-sweep detector. Columns, not JSON: an operator can only alert on what is + filterable. Rows written before this carry `null`, never `0` — "not measured" + must not read as "measured zero", or every legacy row is a false alarm the first + time someone writes that query. + + Two details that decide whether the numbers can be trusted. The summary is + folded from the **full** step log before history compaction, so a + 5000-iteration sweep does not silently report the ~200 steps that fit in + `steps_json`; and rehydration reads the persisted `summary_json` rather than + re-folding those compacted steps. A `subflow` rolls its child's totals into its + parent, so a sweep that delegates its writes is not read as inert — the child + keeps its own run row, and the parent's summary answers "what did this run + cause". + + Additive throughout: `summary` is optional everywhere it appears, existing runs + and stores keep working, and no execution behaviour changes. The one-line log + defaults to `info` — a line nobody sees at their production level is the same + non-signal this closes — with `AutomationServicePlugin`'s + `runSummaryLog: 'debug' | 'off'` to turn the volume down on a very + high-frequency flow without turning the measurement off. + + New spec exports: `FlowRunSummarySchema`, `FlowRunNodeSummarySchema`, + `FlowRunGateSummarySchema`, `ExecutionStepMetricsSchema`, + `ExecutionStepSkipReasonSchema` (+ inferred types); `ExecutionLog.summary` and + `ExecutionStepLog.metrics` / `.skippedBy`. `service-automation` exports + `summarizeRun` / `formatRunSummaryLine` so a host building its own surface + reuses the platform's definition instead of re-deriving one. + + Does not fix #4347 itself — this is the instrument that would have caught it. + + Verified: `@objectstack/service-automation` **522 tests / 46 files** (23 new), + `@objectstack/spec` **7165 / 279** (5 new), `@objectstack/runtime` **974 / 68**, + `@objectstack/plugin-approvals` **330 / 13**; all eight `@objectstack/spec` + `check:generated` gates plus `check:liveness` and `check:exported-any`; and + `tsc --noEmit` on service-automation at its ledgered 2 pre-existing errors. + +- e8d0c21: feat(spec): `FormSection.pane` — explicit split-pane placement (objectui#2153 follow-up) + + A `type: 'split'` form view had no way to say which pane a section renders in: + the renderer hardcoded "first section left, everything else right". That + positional rule is invisible in the metadata — nothing in the JSON records the + assignment — so reordering sections silently moved them across the divider, and + an author (human or AI) could not place two sections side by side on the left at + all. + + `FormSectionSchema` gains an optional `pane: 'primary' | 'secondary'`: + + - **Explicit and per-section**, so placement survives reordering and an agent + editing the view can see — and must preserve — where each section lives. + - **Omitted → the legacy rule** (first section `primary`, others `secondary`), + so existing keyless metadata keeps its exact layout. + - **Split-only, enforced loudly**: a `FormViewSchema` refinement rejects `pane` + on any other form type at parse (covering the legacy `groups` alias and the + defaulted `type: 'simple'`). "Accepted but ignored" is the failure mode this + key must never have — a silent no-op reads as working, especially to an AI + author. zod 4 keeps refinements through `.extend()`, so the flattened + runtime-overlay variant in `ViewMetadataSchema` enforces it too. + - Strict two-value enum, not free text — a typo (`'left'`) is a parse error. + + The `'split'` type's enum comment claimed "Master-Detail split"; master-detail + already has two homes (`subforms` on the form, related lists on record pages), + so the comment now states split's actual, non-redundant meaning: side-by-side + resizable panes with sections placed via `section.pane`. + + The showcase task form's `split` view previously declared a single section — + which renders as a plain (unsplit) form — and now demonstrates the feature: + two sections with explicit panes. + + Renderer support ships in ObjectUI (`SplitForm` → `FormSchema.fieldPanes`, + whose pane keys are already named `primary`/`secondary` — a 1:1 mapping). + +- ab9fb5c: A hook with an empty `object` target is refused instead of silently widened to the wildcard. + + `HookSchema.object` had no emptiness constraint, so `''`, `[]` and `['']` all parsed. The binder's `normalizeObjects` then mapped the first two to `['*']` — the engine's match-everything sentinel — so a hook whose target was left blank registered on **every** object in the tenant, on every event it listed, with no diagnostic anywhere. `['']` failed the other way, registering on an object name nothing matches: a hook that could never fire (ADR-0078). Both shapes are now refused, at parse time and again in the binder (which accepts unparsed input, so the guard has to hold in both places). The error names the two spellings that work and the wildcard the blank silently became. A wildcard hook stays legitimate — it just has to be spelled `'*'`, so it is a choice visible in a diff. + + Also fixes `bindHooksToEngine`'s `strict` option, which is documented as "fail fast on misconfiguration" but never threw: the per-hook `try`/`catch` swallowed the throw its own strict branch raised, recording the failure twice and carrying on. Under `strict` a bind failure is now fatal, as advertised. + +- f9fc874: feat(spec): `element:button` declares the action it executes — `InlineActionSchema` (objectui#2997) + + `element:button`'s renderer reads `properties.action` and dispatches it through + the `ActionRunner`; that prop is the only thing making a standalone-page button + interactive. `ElementButtonPropsSchema` declared `label`, `variant`, `size`, + `icon`, `iconPosition`, `disabled`, `aria` — and no `action`. + + It is being authored anyway. cloud's `service-tenant` pages carry five + declaration sites across the billing and pricing funnel (`pricing`, `welcome`, + `billing-cancel` ×2, `billing-success`), each `{ type: 'navigation', to: … }`, + each cast `as any` to get past the type system. + + **An undeclared prop is stripped, not ignored.** + `ElementButtonPropsSchema.safeParse({ label, action })` returned `success: true` + with no `action` in `data` — a non-strict `z.object` drops unknown keys. That is + harmless only because page block `properties` are still + `z.record(z.string(), z.unknown())`, so this schema never runs on a real page + save. The moment anyone tightens `properties` to validate against + `ComponentPropsMap` — an obvious hardening — every one of those buttons loses its + action at save, with a green parse. Declaring the prop is what defuses it. + + **`InlineActionSchema` is derived, not a new dialect.** `ActionSchema` is + `z.object(…).refine(…).refine(…)`, so `.pick()` is unavailable on the exported + schema — the object half is now a factory both schemas build from, which keeps + `lazySchema`'s deferral (the fields are constructed on first use of whichever + schema is touched, not at module load). The inline schema `.pick()`s the twelve + fields `element:button` actually forwards to the runner, so their `describe()` + text, the `ActionType` vocabulary and the `target`-required refinement are shared + rather than restated. + + `name` and `label` are optional, which is the substantive difference: + `ActionSchema` requires both because a registry entry needs an identity and a + menu label, and an inline action has neither — the button already has its own + `label`, and requiring `action.label` too would mean writing it twice. Everything + that only means something for a registered action — `objectName`, `locations`, + `order`, `ai`, `requiredPermissions`, `visible`/`disabled`, `resultDialog` — is + excluded, as are `icon`/`variant` (the button has its own) and `body` (a page + button running an inline sandboxed script is a separate decision). A declared + field no renderer reads is the failure this schema exists to stop, so widen it + when a renderer widens, not before. + + **`navigation` and `to` become normalizing aliases, not new members.** + `normalizeInlineAction` folds `type: 'navigation'` → `'url'` and `to` → `target` + on parse, the `VIEW_FILTER_OPERATOR_ALIASES` pattern. So cloud's existing pages + keep validating unchanged while parse output is always canonical, and the aliases + get a `retiredKey` tombstone once the producers are migrated. `url` is also the + _better_ target: the runner's `url` path has `${param.X}` / `${ctx.X}` + interpolation, `apiBase` promotion for `/api/…` paths and popup-blocker-safe + `openIn`, none of which its `navigation` path has. + + Deliberately **not** done: promoting `navigation` into `ActionType` as a bare + member, which was considered and declined in #4070. It would name the type while + leaving the prop undeclared and `to` homeless — half a fix, and a permanent + synonym in a vocabulary whose members cannot be removed later. + + Nothing is narrowed. `ui/InlineAction` and `ui/ElementButtonProps:action` are + additions to every generated surface; no accepted value stops being accepted, so + no stored page metadata changes meaning. + + Verified: 16 new tests — the derivation is asserted from both directions + (the inline field set is exactly the documented twelve; every one of them + round-trips through `ActionSchema`; every registry-only concern is absent; every + `ActionType` member is inline-authorable), and the fold is pinned against the + literal shape cloud writes. Full `@objectstack/spec` suite **7038 tests across + 271 files**, `tsc --noEmit`, and all twelve `check:*` gates, clean. + +- 011b386: Reconcile the flat IO nodes' declared config against what their executors read + (#4045 — the notify / http / connector step of the declared-vs-read worklist). + + **`notify` / `http` gain executor-derived Zod contracts.** + `NotifyConfigSchema` and `HttpConfigSchema` (`automation/io-node-config.zod.ts`) + were written by reading the executors — not by transcribing the descriptors' + hand-written `configSchema` literals — and a new ledger test + (`io-node-form-zod-ledger.test.ts`) compares the two key sets bidirectionally. + Because the sides are independently written, agreement is evidence rather than + tautology: a key survives only if the form offers it AND the executor reads it. + Both nodes reconcile clean, with no deliberately-shallow ledger — their configs + are flat and fully closed. Like the control-flow config Zods, these are contract + exports: no engine path parses with them yet (that is #4045 step 3b, gated on + the #4059 warning data). + + **`connector_action`'s mis-rooted `configSchema` is retired — it broke + schema-driven authoring.** The executor reads only the declared + `FlowNodeSchema.connectorConfig` sibling block, but the descriptor published a + `configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys. A + published `configSchema` describes `node.config` by contract, and the Studio + inspector derives its property form from it — rooting every field at + `config.` and replacing the client's hand-written `connectorConfig` form + (with its connector/action pickers). So authoring a connector node against a + live backend wrote the trio where nothing reads it, and the node refused to + dispatch. The descriptor now publishes no `configSchema` (joining `wait`'s + deliberately-schemaless class), which drops the online designer back onto the + correct sibling-block form with no client change. + + **Stored flows that carry the mis-taught shape are healed at load.** A new + ADR-0087 D2 conversion, `flow-node-connector-config-lift` (protocol 17, retires + at 18), lifts `config.{connectorId,actionId,input}` onto the declared + `connectorConfig` block — including the `AutomationEngine.registerFlow` + rehydration seam. Declared keys win (the loose counterpart stays shadowed), and + a lift that cannot complete the required `connectorId`+`actionId` pair leaves + the node untouched, so a step-time refusal never becomes a load failure. + + **`connectorConfig.input` is now optional**, matching what was always true: the + executor dispatches with `input ?? {}` and the designer's keyValue editor omits + an empty map entirely — so the required `input` declared in the spec turned a + no-input connector action into a `registerFlow` parse failure nothing + downstream asked for. + +- 507b92a: fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957) + + The write path built every built-in validation message by concatenating the **API + field name** into a **hardcoded English** template. Those strings are what the + Console toast, the CSV-import row report, the CLI and any custom client display + verbatim, so a Chinese-locale user importing a bad row read: + + ``` + 第 1 行:penalty_amount must be ≥ 0 + ``` + + …for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The + form layer localized the _same_ constraint correctly (the browser's native + `min`), so the language flipped depending on which layer caught the value. + + **Three things changed.** + + 1. **The message is rendered in the caller's locale** from a built-in catalog + (`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`, + `zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles. + The locale comes from `ExecutionContext.locale`, whose contract already read + "Drives message catalogs"; this is the consumer that makes that true. Both + HTTP entries (REST server, runtime dispatcher) now resolve it from the + request's `Accept-Language` / `?locale` first, falling back to the workspace + `localization.locale` — so a rejection message and the field labels around it + can no longer disagree. + + 2. **The field is named by its label, never the API name**: translation bundle + (`objects..fields..label`) → declared `label` → API name as the last + resort. `FieldValidationError.field` still carries the API name so a form can + focus the right input. + + 3. **The constraint is exposed as data**, so a client can format its own text + instead of parsing the sentence: + `{ field, code, message, label, constraint: { min: 0 } }`. This rides + ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema` + (`constraint` tightens from `unknown` to `Record`) rather + than adding a parallel payload — `label` is the only new field. The bag + carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the + message templates interpolate from exactly those keys. + + Covered end-to-end, not only in the validator: single and batch insert, + single-id and multi-row update, ADR-0113's clear-out rejection, the object-level + rule evaluator's own built-in messages (`requiredWhen`, per-option gating, + state-machine fallbacks), and the importer's cell-coercion, required pre-check + and #3956 bound pre-check messages — all of which land in the same row report. + + **What this changes for consumers.** + + - `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to + match on. Message keys are finer-grained than codes — `invalid_datetime`, + `invalid_option_value`, `required_cleared` are rendering detail and never reach + the wire — so localization never splits the client-facing vocabulary. + - `message` **text changes**: it is localized, and it names the field by label + even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything + asserting on the old English string should match `code` (and now + `constraint`) instead. + - An author-written validation-rule `message` is never touched — it is already + in the language its author chose. + - A deployment can override any built-in message with a `translation` item + defining `validation.field.` (e.g. + `validation.field.min_value: '{{label}}不得小于 {{min}} 元'`). + - The importer's reference-failure message no longer names the target object's + API name (`no sys_user matches "…"`): naming internal identifiers is the + defect being fixed, and the column plus the offending value are what an + importer can act on. + +- 20bc1ec: fix(spec,rest): the metadata forms save what they show — form ↔ Zod reconciliation (#3786) + + Every entry in `METADATA_FORM_REGISTRY` is a hand-written `defineForm` layout + that names keys of a Zod schema it never imports: two descriptions of one key + set, a comment asking the next author to keep them in step, and nothing that + fails when they don't. #3786 asked for a sweep of that shape across the repo. + **Four of the seventeen forms had already drifted, every one of them silently.** + + The silence is the point. `ObjectSchema` / `FieldSchema` are deliberately not + `.strict()`, so a key the schema does not declare parses clean and is stripped + on the way to storage — the same ADR-0104 failure class the `field.zod.ts` + prune tombstone already describes in prose. An admin toggled a switch in + Studio, got no error, and the value never landed. + + **What was broken, from an author's seat:** + + - **Object → Capabilities.** The block bound to `capabilities`; the + `ObjectSchema` key is `enable`. All seven toggles (Track history, Searchable, + API enabled, Files, Feeds, Activities, Clone) saved nothing. + - **Object → Fields.** The inline column grid offered 16 keys `FieldSchema` has + never declared. `PII`, `Encrypted`, `Indexed`, `Immutable`, `Filterable`, + `Placeholder`, `Validation`/`Error message` and `Starting number` were + controls with no storage behind them at all; the rest named keys the schema + had **renamed** and the form never followed: + `referenceFilter` → `lookupFilters`, `cascadeDelete` → `deleteBehavior` + (a three-way enum, not a boolean), `formula` → `expression`, + `displayFormat` → `autonumberFormat`, and the flat `summaryType` / + `summaryField` pair → the single `summaryOperations` object, which also + restores the `object` key the flat pair had no slot for. Roll-ups authored in + that grid saved nothing. + - **Report → Advanced.** `aria` and `performance` were pruned from + `ReportSchema` by #3496; the form kept rendering both. + - **Hook / Action → Body.** `memoryMb` was unauthorable — named in + `hook.form.ts`'s own doc comment, absent from the list beneath it. + - **Page → Interface.** `interfaceConfig.sort` was unauthorable, so a page's + default sort order could not be set in Studio at all. + + **No authored metadata changes and nothing you can write is removed.** These + were UI controls that never persisted; every corrected key is one `FieldSchema` + / `ObjectSchema` already accepted. Metadata authored in YAML/TS was always + validated against the real schema and is unaffected. If you had been filling + those Studio controls expecting them to stick, they now either work (the + renamed five) or are gone rather than lying to you. + + The metadata-form translation bundles are derived from the registry, so all + four locales are regenerated. Worth naming what they contained: translated + labels, in four languages, for switches that saved nothing — the drift had + propagated into a generated artifact and been dutifully translated there. + + **The mechanism.** `metadata-form-zod-reconciliation.test.ts` walks every + registered form and reconciles it against `getMetadataTypeSchema()`. The two + directions are deliberately asymmetric: **form-only** (a control whose value is + discarded) is always a defect and cannot be excused, because no design wants + one; **zod-only** is ledgerable with a reason, for a deprecated key held back + from new authoring or a curated quick-add subset that defers to a fuller + editor. Ledger entries are checked for non-vacuity and for still resolving on + both sides, per the #4045 / #4040 discipline. Verified by mutation — re-adding + a stripped key, dropping a covered key, and offering a ledgered omission each + turn the gate red. + + **New export: `TRANSLATABLE_METADATA_TYPES`** (`@objectstack/spec/system`), the + set of metadata types whose labels `translateMetadataDocument` localizes, + derived from its dispatch table rather than restated. `@objectstack/rest` had + been carrying a hand-copied literal set under a "keep in sync with the type + dispatch" comment; it now reads this instead. Registering a translator in spec + reaches the REST boundary with nothing else to remember — the second list is + deleted rather than checked, which is the better half of derive-or-gate. + + Also corrected: `ActionAiCategorySchema`'s comment claimed it mirrored + `ToolCategorySchema` in `ai/tool.zod` and told the next author to update both + sides — but #3896 deleted `ToolCategorySchema` along with the inert + `tool.category` key it typed. The instruction had been pointing at a source + that no longer exists. The enum is canonical now and says so. + +- 42eeb7d: fix(spec): `defineApp` type-checked its navigation again — the #4171 fix only covered half the annotation (#3786) + + `NavigationItemSchema` is recursive, so it cannot infer its own type and carries a + hand-written annotation instead. #4171 fixed that annotation's **Output** half: it + had been `z.ZodType`, which made the exported `NavigationItem` `any` for every + consumer — a type that constrains nothing, which reads exactly like a type that + works. + + `z.ZodType` takes two parameters, ``, and **`Input` defaults to + `unknown`**. Naming only the first left the input half at that default, so + `z.input` resolved `navigation` to `unknown` — and `unknown` + accepts everything. `defineApp(config: z.input)` is the documented + authoring entry point, and it took + + ```ts + defineApp({ + name: "my_app", + label: "My App", + navigation: [{ totally: "made up" }, 42, "nonsense"], // compiled clean + }); + ``` + + with no complaint. Every authoring path through the app schema — `AppInput`, + `NavigationAreaSchema`, `NavigationContributionSchema` — was unchecked the same way. + Parsing was never affected: the schema rejected all of the above at runtime. It was + only the compile-time contract that lied, which is why nothing in the test suite + noticed. #4171's fix was verified through `z.infer`; nobody re-measured `z.input`, + and half a fix looks identical to a whole one. + + **The fix.** A new exported `NavigationItemInput` describes the authoring side, and + the annotation now names both parameters. The two unions genuinely differ and one + cannot serve both: `GroupNavItemSchema.expanded` and `UrlNavItemSchema.target` carry + `.default()`, so those keys are **required** in the parsed output and **omissible** + when authoring. Reusing `NavigationItem` as the input type would force authors to + write values the schema exists to supply. + + **What this changes for you.** Nothing at runtime, and nothing for code that reads a + parsed app. Code that _authors_ navigation through `defineApp`, `AppInput`, or + `z.input` of any schema embedding `NavigationItemSchema` is now type-checked where it + previously was not, so genuinely malformed navigation that used to compile will now + surface as a compile error — the errors are pre-existing bugs becoming visible, not + new restrictions. Two notes on what the checked type says: + + - Authoring types (`AppInput`, `NavigationItemInput`) let you omit `expanded` and + `target`; the parsed types (`App`, `NavigationItem`) still guarantee both are + present. + - If you annotate a hand-written literal with the parsed type (`const APP: App = {…}`) + rather than the authoring type, you must spell out every defaulted key. That was + already true and is unchanged — this release just makes the authoring alternative + actually check its contents. + + **The mechanism, not a comment.** `src/ui/app.nav-type-assertions.ts` is a non-test + `src` module (the package's `tsc --noEmit` CI gate excludes test files) holding + compile-level probes for both unions and, critically, for the **wiring** between them + and the schema. A correct `NavigationItemInput` that no schema references would leave + every authoring path back at `unknown` — precisely the state being fixed — so the + load-bearing assertions go through `AppInput` and fail the moment the annotation loses + its second parameter. Each probe was mutation-tested: dropping the `Input` parameter, + removing the `expanded` default, and leaking `children` onto a flat nav branch each + turn the corresponding assertion red. + +- 7ce02eb: feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3) + + ObjectQL registers one instance under two names, and the ledger can finally say + what each name means: `data` stays `IDataEngine` (the data plane), `objectql` + now resolves to **`IObjectQLEngine`** — the full engine: schema access + (`getSchema` / `getObject` / `registry`), actions (`registerAction` / + `removeActionsByPackage` / `executeAction`), the hook/middleware seams + (`registerHook` / `unregisterHooksByPackage` / `registerFunction` / + `registerMiddleware` / `bindHooks`), the first-wins default runners and hook + metrics, boot wiring (`registerDriver` / `setDatasourceMapping` / + `registerApp`), and the ops probes (`checkDriversHealth` / + `wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger + test pins the new relation: `objectql` strictly widens `data`, deliberately no + longer equal. + + **Why now, and why `implements` is the point.** The honest state for two + batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider + than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine` + would be "the more comfortable-looking lie". The interim discipline — each + consumer declares the narrow slice it uses — produced seven local surfaces + (`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`, + `SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline + `checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and + each was an UNCHECKED claim: `getService('objectql')` is an assertion, + so an engine rename would have broken every consumer at runtime with zero + compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into + one compiler-verified claim. All seven stand-ins are deleted; consumers import + the one declaration. `getObjectQL` is typed `Promise` + end to end, closing the oldest documented `any` in the dispatcher. + + **Evidence bar unchanged.** Every declared member has a cross-package consumer + reaching it through the slot; engine members without one (e.g. `triggerHooks`, + cross-package only in tests) stay off until a caller appears. The registry view + (`EngineSchemaRegistryView`) declares exactly the eight members consumers use. + + **`_registry` never leaves the engine package now.** plugin-security's + declared-metadata readers (`readDeclared`, permission-set projection, suggested + audience bindings) reached ObjectQL's private `_registry` field through `any` — + the same private reach `/me/apps` had in B2, five more times. All migrated to + the public `registry` getter the contract declares, test doubles included. + + **`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by + `MetadataManager` beside `watch` all along, reached through the slot only via + `any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored + hooks/actions live). With them declared, the bridge's six `metadata` lookups + and metadata-protocol's `objectql` lookup carry contract types, and both files + leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34 + files**. + +- 7733604: feat(spec,cli): publish the foundational capability slate so every runtime reads one list (#3786, cloud#925) + + `Serve.ALWAYS_ON_CAPABILITIES` — the capabilities auto-added to every app's + `requires` outside `--preset minimal` — was declared in the CLI, under a comment + noting that "cloud / multi-environment hosts (which live in a separate + distribution) mirror this list on their per-project kernels." + + Nothing made that true, and they had already diverged. Cloud's per-tenant slate + was missing **`sms`, `messaging` and `analytics`**, so an app that worked under + `objectstack serve` could lose `notify` deliveries and dataset previews once + hosted — silently, with no error anywhere. The framework's own comment on + `analytics` spells out the failure mode it was made always-on to prevent: + "Without it the dataset preview + dashboard/report analytics widgets silently + no-op." + + **New export: `PLATFORM_ALWAYS_ON_CAPABILITIES`** (`@objectstack/spec`, and + `@objectstack/spec/kernel`). The slate and its per-entry rationale now live + beside `PLATFORM_CAPABILITY_PROVIDERS` — the map published for exactly this + reason one release earlier, "so cloud's objectos-runtime and the framework CLI + classify a `requires` token identically". `Serve.ALWAYS_ON_CAPABILITIES` is now + a re-export of it, kept as a stable handle for existing callers rather than + deleted: one declaration, two readers. + + Four assertions make the single declaration trustworthy for both of them — the + slate is frozen, deduped and non-empty; its foundational prefix + (`queue, job, cache, settings, email, storage`) is pinned, because mount order + matters when services bind to each other during `kernel:ready`; every member is + a real `PLATFORM_CAPABILITY_TOKENS` entry; every member has a declared provider; + and every member is `edition: 'open'`, since a floor the open distribution + cannot mount is not a floor. Verified by mutation: an unknown token, an + enterprise-edition token, and a reordered prefix each turn the gate red. + + **No behaviour change.** The published slate is byte-identical to the list the + CLI already had, and `serve-defaults.test.ts` / `serve-capability-vocabulary.test.ts` + pass unchanged. What changes is that there is now something to derive from: + cloud's hosted runtime can drop its copy and read this instead, which is the + follow-up cloud#925 left open — it lands there once the `.objectstack-sha` pin + moves past this release. + +- d13004a: feat(core,runtime): plugin ordering is a declared, kernel-enforced contract (ADR-0116, #4131) + + `kernel.use()` registration order was never a contract — the kernel resolves + init/start order from the plugin dependency graph — but a plugin that needed a + service at init _when its provider is composed_ while also booting _without_ + the provider had no way to declare that. `AppPlugin` was the standing example: + it grabs `manifest`/`objectql` synchronously in `init()`, declared nothing + (a hard dependency would break empty-env / metadata-only / mock-engine + kernels), and so its correctness rode on which array slot each caller put it + in. That convention failed the same way twice (`DefaultDatasourcePlugin`'s + first cut; then #4085, disguised for months as "crashes when the artifact is + missing"). + + The kernel `Plugin` contract gains three additive fields, enforced by both + `ObjectKernel` and `LiteKernel` through one shared implementation + (`plugin-order.ts` — the previously duplicated topological sort is unified + there): + + - **`optionalDependencies: string[]`** — order-if-present: hoisted ahead + exactly like `dependencies` when composed (real topology edges, including + cycle detection), silently skipped when absent. + - **`requiresServices: string[]`** — services resolved synchronously during + `init()` with no fallback. Validated **before Phase 1**: a required service + whose only declared provider initializes later fails the boot with an error + naming both plugins, both slots, and the fix — before any init side + effects. Re-checked immediately before the plugin's own init, where a still- + missing service becomes a named composition error exactly where the old + bare `Service not found` crash fired. + - **`providesServices: string[]`** — services a plugin's `init()` + unconditionally registers; powers the validation and the diagnostics. + + Plugins that declare nothing get the diagnosis too: a `getService` miss + during Phase 1 now appends which plugin was initializing and — when a + composed plugin declares the service — who provides it and how to declare the + ordering. The `Service '' not found` prefix and the factory-backed + `is async - use await` message are unchanged. + + First adopters: `AppPlugin` declares + `optionalDependencies: ['com.objectstack.engine.objectql']` + + `requiresServices: ['manifest']` (cleared on the empty-env no-op path), so + the #4085 composition — AppPlugin registered before the engine — now boots + correctly in every slot; `ObjectQLPlugin` declares + `providesServices: ['objectql', 'data', 'manifest', 'lifecycle']` and + `MetadataPlugin` declares `providesServices: ['metadata']`. + + Everything is additive — plugins that declare nothing keep their exact + ordering semantics; no existing declaration changes meaning. + +- b09d8d9: feat(objectql)!: `query.having` is enforced — the engine applies it after aggregation (#4286 step 3, ADR-0049 resolved to enforce) + + `having` had been declared on the request surface since AST v2 and executed by + nothing. #4286 finding 1 showed the gap was structural: `engine.aggregate()` + rebuilt the driver AST with exactly `object`/`where`/`groupBy`/`aggregations`, + so even a driver that _did_ implement HAVING could never have received it, and + the one wire path (`findData`'s aggregate branch) dropped the clause too. It + was the strongest enforce candidate of the #4286 set — the clause every + SQL-literate author (human or model) expects to work next to + `groupBy`/`aggregations` — and it is now live end to end: + + - **Engine-owned, both paths.** `applyHaving()` + (`packages/objectql/src/having-filter.ts`) runs AFTER aggregation on the + native-driver path and the in-memory fallback alike — the same + correct-first / optimize-later two-tier shape date bucketing uses. Native + SQL `HAVING` pushdown can come later behind a driver capability flag without + changing semantics. + - **Namespace: the aggregated row's own columns** — aggregation aliases + (`order_count`, `total`) and groupBy projections — with the ordinary + FilterCondition operators plus `$and`/`$or`/`$not`. + - **An unknown operator rejects loudly.** Ignoring one (as tolerant matchers + do) would silently return unfiltered aggregates — the exact ADR-0078 + silently-inert failure enforcement exists to end. + - **The wire path forwards it.** `findData`'s aggregate branch passes + `having` through, and `EngineAggregateOptionsSchema` now declares it. + - The FLS predicate guard already walked `having` references + (`predicate-guard.ts`), which is what made enforcement safe to turn on. + + No migration needed: queries that carried `having` before were silently + returning every group; they now filter as written. A caller who depended on + the clause being _ignored_ (sending `having` and expecting unfiltered + results) sees the corrected behavior — that is the enforcement, not a + regression. + +- 3eb1b2b: feat(lint): every field-bearing prop on a React page block resolves against the + object it names + + #4329 closed ONE of them — `` — by running the + metadata rule's core from the gate that owns React block props. That prop was an + instance, not the class: every other prop a `kind:'react'` page binds BY FIELD + NAME shipped exactly as typed, the same silent drift `page-field-unknown` + already closes for the page-component `properties` bag one surface over. + + `validate-react-page-props` now resolves all of them: + + - `` `fields` / `columns` / `sort` / `grouping` / `userFilters` / + `hiddenFields` / `fieldOrder` / `filterableFields` + - `` `fields`, `initialValues` KEYS, `sections[].fields[]` + - `` / `` / `` / + `` — via the SAME `COMPONENT_FIELD_SPECS` table the + metadata surface uses, keyed by the block's `schemaType`, so the two surfaces + agree by construction rather than by two lists that happen to match + - `` — the escape hatch reaches the same table by the type the + author writes, so it is checked instead of being a hole + + Findings carry the metadata rule's id (`page-field-unknown`) at its advisory + severity, because the consumer behaves the same way: an unknown name is skipped + and the rest renders. + + **A FILTER position gates instead.** `` / `` name fields in a QUERY, and an unknown column there is not a skipped + column: the predicate can never match, `SqlDriver` swallows the driver's + "no such column" and returns `[]`, and the surface renders an empty list that + looks exactly like "there is no data" — the silent zero `filter-token-unknown` + and `validate-flow-template-paths`' filter-position call both gate on. Those + are reported as `error`. + + Filter positions are also resolved INDEPENDENTLY of each other, unlike every + other value this gate reads. `filters={['status', '=', stage]}` — a static field + beside a React-state value — is the shape a react page actually writes, and the + all-or-nothing static reader skipped the whole array, including the one position + that was knowable. + + Everything else is unchanged: a value from a variable, a call, or behind a + spread is unresolvable rather than wrong and is skipped silently (ADR-0072 D1), + as are cross-package objects, objects with no authored field map, dotted + relationship paths, and registry-injected system columns. + + ### Breaking: `` is the RELATED object, as the spec always said + + `RecordRelatedListProps.objectName` is the related (child) object — that is what + `record:related_list` means on every metadata surface, what + `validate-page-field-bindings` resolves its `columns` against, and what the one + registry component behind both surfaces consumes. The React overlay declared + `objectName` a SECOND time and glossed it "The parent object", and the generated + contract publishes the overlay's description in place of the schema's — so the + react surface both contradicted the spec and lost any way to name the object it + renders. + + FROM → TO for a page authored against the old gloss: + + ```diff + - + + + ``` + + `objectName` names the CHILD object being listed; the parent record stays bound + by `recordId`, and `relationshipField` is the child's field pointing back at it. + The lint above reports the old spelling (the child's columns and its FK do not + resolve against the parent). `objectName` is now also published as required, as + the schema declares it. + + The class is closed as well as the instance: `REACT_OVERLAY_SHADOWS` in + `@objectstack/spec/ui` ledgers every overlay prop that restates a spec-schema + prop, and a test asserts the ledger equals the real collision set — so the next + overlay entry that silently redefines a schema prop fails a test instead of + shipping a second dialect. + +- d6938bf: fix(spec): the remaining six recursive schemas name both type parameters, and the authoring artifacts stop spelling out defaults (#4195) + + #4221 fixed `NavigationItemSchema` — the worst instance, and the one with a + reproducible "`defineApp` compiles `navigation: [42, 'nonsense']`" demo. This + finishes the sweep: **six more schemas** had the same shape, and the authoring + artifacts that #4171 had to work around can now be typed honestly. + + `z.ZodType` takes `` and `Input` defaults to `unknown`, so naming + only the first parameter leaves `z.input` of anything embedding that schema at + `unknown`. Measured with a type probe: + + | | was | now | + | -------------------------------------- | ----------- | ------------------------- | + | `QueryInput['joins']` | `unknown[]` | `JoinNodeInput[]` | + | `QueryInput['fields']` | `unknown[]` | `FieldNode[]` | + | `z.input` | `unknown` | `FormFieldInput` | + | `z.input` | `unknown` | `QueryInput` | + | `z.input` | `unknown` | `StateNodeConfig` | + | `z.input` | `unknown` | `BaseValidationRuleShape` | + + New exported types: `FormFieldInput`, `JoinNodeInput`, `NavigationContributionInput`. + `FilterCondition`, `NormalizedFilter` and `FieldNode` carry no `.default()` or + `.transform()`, so their input is their output and the second parameter is the + first. + + **The `z.ZodType` single-parameter form is now absent from the codebase.** + + ## 26 hand-written defaults deleted + + This is the half #4221 left on the table. #4171 had to spell out + `expanded: false` (×16) and `target: '_self'` (×10) across `setup.app.ts`, + `studio.app.ts` and `setup-nav.contributions.ts`, because those artifacts are + annotated with the PARSED type where a `.default()`ed key is required — and + retyping them to the input surface would have traded eight loud errors for no + checking at all. + + With `NavigationItemInput` landed (#4221) and `NavigationContributionInput` + added here, they are annotated `AppInput` / `NavigationContributionInput`, the + defaults are defaults again, and the literals are checked for the first time. + Net across those four files: 21 lines added, 54 removed. + + Verified live, not nominal: a literal omitting `expanded`/`target` compiles, and + one writing `defaultOpen` — the non-spec key #4171 found in `account.app.ts` — + is a compile error whose suggestion list names `expanded`. + + ## Two typed with a documented caveat + + `StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for + both parameters: exact on the input side, loose on the output side. + `StateNodeConfig` marks `type` optional though `.default('atomic')` makes it + always present; `BaseValidationRuleShape` carries a `[key: string]: unknown` + index signature. Both were already that loose — input went from `unknown` (types + nothing) to a real type, output is untouched. Making them exact means deriving + those types from their schemas instead of maintaining them beside one, which is + separate work; the caveat is written at each declaration rather than left for a + reader to find. + + ## Why there is still no CI gate for this + + Worth recording, since #4195 proposed one: extend `check:exported-any` to fail on + "output precise but input `unknown`". Measured after this change — exactly two + schemas match, `TranslationItemSchema` and `InlineActionSchema`, and **both are + correct**: they are `z.preprocess(...)`, where an `unknown` input is zod's + semantics rather than a missing annotation. Separating those from a genuinely + missing parameter needs heuristics on emitted type names, and per the rule in + that script's own header — zero false positives, so red keeps meaning broken — a + gate that cannot be made reliable is worse than none. #4221's + `app.nav-type-assertions.ts` is the better pattern where it applies: pin the + contract at compile level rather than infer intent from shape. + +- cd6b9f2: `decisionOutputs` entries may now be declared `required` (objectui#2955). A typed entry `{ key, label?, type?, multiple?, required?: true }` tells the runtime — not just the decision UI — that an approver must supply the value: an **approve** carrying no value, or a blank one (`''`, whitespace, `[]`, an array of blanks), is rejected with `VALIDATION_FAILED` before any write, so the audit row and the request are untouched and the run can never resume past the node with the key missing. + + That gap is what the flag closes. `decisionOutputs` exists so a decision can route the next step (`approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }]`), but nothing made the approver actually answer: a skipped output resumed the run with the key absent, and the next node either faulted with `EXPRESSION_FAILED` or resolved an empty slate and stalled on `onEmptyApprovers: 'admin_rescue'` — long after the one person who could have filled it in had moved on. `onEmptyApprovers` was the only backstop, and it is a recovery mechanism, not a contract. + + **Reject never requires them.** The run leaves down the `reject` edge, where nothing reads the outputs — demanding routing data to say "no" would trap the rejection. Outputs still ride a reject when the approver filled them in. + + **No elevation bypass.** A one-click email action link and an `auto_approve` SLA escalation both fail the same way rather than advancing into a node that would resolve nobody; the escalation sweep already isolates a throwing request, so that decision stays pending and visibly overdue instead of silently breaking the run downstream. Enforcement is per decision, so on a `unanimous` / `quorum` node every approver supplies the required outputs and the finalizing decision's values are what the flow resumes with. + + `required` rides `normalizeDecisionOutputs`, so it reaches clients on `decision_output_defs` — a decision UI marks the field required and blocks locally instead of round-tripping to a 400. The console side ships in objectui#2955. + +- 2cb6d3c: fix(spec,runtime): `resolveService` returns the slot's contract too, and the `: any` escapes on core slots are gone (#4127) + + Batch 2 of the #4127 gate. #4168 typed `getService` — easy, because every one of + its call sites already passed a `CoreServiceName`. `resolveService` is the mixed + one, and it is where the remaining `any` lived. + + **Overloads split it exactly where the evidence does.** A `CoreServiceName` + resolves to the slot's contract; anything else keeps `any`: + + - **Core slots, however written.** 17 call sites address a core slot with a bare + literal — `'metadata'` ×10, `'automation'` ×3, `'auth'` ×3, `'ai'` — rather + than `CoreServiceName.enum.*`. The same slot was being addressed two ways; + both resolve to the contract now, with no edit to the call sites. + - **Everything else** — `protocol` (×22), `objectql` (×9), `mcp`, + `kernel-resolver`, `security`, `scope-manager`. Real services with no + `CoreServiceName` entry and no written contract. They keep `any` rather than + being given a shape here that nothing verifies: **that `any` is where the + ledger honestly ends**, and writing those contracts is its own change. + + **The typing was being erased at three call sites, and that is the actual + finding.** A `const x: any = await deps.resolveService('auth', …)` defeats every + bit of this — the annotation wins, and #4168's work does nothing there. Sweeping + for the pattern found three on core slots: + + **`/mcp` ×2 — two more undeclared methods.** The domain calls + `authService?.getMcpResourceUrl?.()` and `?.getMcpResourceMetadataUrl?.()`. + `AuthManager` implements both (and plugin-auth uses them internally); + `IAuthService` declared neither. Classic #4127 shape — call site and + implementation agree, the contract is the thing nobody wrote. + + The `: any` + optional-chaining combination made this _worse_ than the earlier + gaps, not better: it made the call invisible to the type system **and** + accidentally safe. An absent method returns `undefined`, so the skill route + silently fell back to deriving an MCP URL from the request host — meaning a real + disagreement between the auth service's canonical value and the derived one + would have looked exactly like normal operation. The whole point of + `getMcpResourceUrl` is that it comes off the auth `basePath` so the two _cannot_ + disagree about the API prefix; the route's own comment says "the auth service + owns the canonical value". + + Both are declared optional: an auth provider without MCP/OAuth support fills the + slot legitimately, and `getMcpResourceMetadataUrl` returning `null` (OAuth track + off — AS disabled or the origin fails the OAuth 2.1 transport rule) stays + distinct from the method being absent. + + **`/packages` ×1 —** `const metadata: any = await deps.getService(…metadata)`, + feeding `new SeedLoaderService(ql, metadata, …)`. Annotation dropped; it + typechecks against `IMetadataService` now. Its neighbours `protocol` and `ql` + keep their `any` for the honest reason above. + + No other core-slot lookup is annotated away — the sweep is exhaustive over + `domains/*.ts`. + + Verified: `@objectstack/runtime` **937 tests / 65 files**, `@objectstack/spec` + **7112 / 273** (3 new on the auth contract), adapter-hono **73**; `tsc --noEmit` + on spec, runtime, downstream-contract and all four examples; `pnpm lint`; all + nine `check:*` gates. `api-surface.json` is unchanged — the two additions are + interface MEMBERS, not new exports. + +- a2266a6: fix(spec,data): the five RPC query aliases resolve by ONE fold — spec table, not per-reader prose (#3795) + + `RpcQueryOptionsSchema` accepts five legacy aliases next to their canonical + QueryAST keys and stated the precedence in prose only ("the normalizer uses + the new key"). With no fold in the schema, every reader re-implemented it — + the #3713 condition — and the two readers disagreed: + + | pair | spec prose | runtime dispatcher | metadata-protocol | + | --------------------- | ---------- | ------------------ | ----------------------------- | + | `where` > `filter` | canonical | canonical | **alias consulted first** | + | `fields` > `select` | canonical | canonical | **alias clobbered canonical** | + | `offset` > `skip` | canonical | canonical | **alias clobbered canonical** | + | `expand` > `populate` | canonical | — | **alias consulted first** | + | `orderBy` > `sort` | canonical | canonical | canonical | + + Four of five inverted in `protocol.ts`, so `?select=a&fields=b` answered + `[a]` on one path and `[b]` on the other — reachable from a plain HTTP + request. + + **The mapping now lives once, in the spec** (`RPC_QUERY_ALIAS_SLOTS` + + `foldQueryAliasSlots`, both exported), under the rule #4181 already + established for the filter pair: + + - an **alias alone** folds into its canonical key — `filter`→`where`, + `select`→`fields`, `sort`→`orderBy`, `skip`→`offset`, `populate`→`expand` — + and the alias key is **dropped from the parsed output**; + - **both spellings, same value**: redundant, tolerated, alias dropped; + - **both spellings, different values**: irreconcilable — picking a winner IS + the silent drop — so the parse fails (schema) / the request is `400 +INVALID_REQUEST` (wire), naming the spellings and the canonical key; + - an explicit **`null` spelling is a withdrawal**, never a conflict: a null + alias is dropped silently, a null canonical keeps its slot-specific answer. + + `RpcQueryOptionsSchema` and the four `filter`-mixin option schemas + (update/delete/count/aggregate requests) apply the fold as a parse transform, + so parsed output speaks canonical keys only — a TS consumer reading + `parsed.query.populate` now **fails to compile** instead of silently reading + `undefined` (the #3742 / #3764 shape, one layer down; hence the minor). The + protocol normalizer folds raw wire input by the same table (extended with the + wire-only `filters` / `$filter` / `$expand` spellings), and the runtime + dispatcher's second copy of the fold is deleted outright. + + **Authoring/callers unchanged for the supported cases**: every alias alone + keeps working on every path, and identical duplicates still pass. What + changes is mixed vocabularies with **different** values — previously answered + differently per route, now refused loudly on all of them — and a direct + `expand: [names]` array on `POST /data/:object/query`, which used to be read + by its indices ("Unknown field '0'") and now lowers to the expand record like + `populate` always did. + +- d25a0ec: feat(spec,service-automation): a run says when its `acted` count is incomplete, instead of guessing (#4354) + + #4354 shipped `selected` / `acted` counts on every flow run, sourced from the + executors that know what they did. Four node types were left out — and the gap + was not cosmetic: `connector_action`, `http` and `script` are how a flow acts on + anything _outside_ the platform, so a sweep whose whole job runs through them + reported `acted: 0` and looked exactly like the dead sweep the counter exists to + find. A detector that fires on healthy runs is worse than no detector: operators + tune it out, and then it is not watching the flows that really did stop. + + Closing it needed a third answer, because for two of those nodes the platform + genuinely cannot know: + + **`connector_action` — unknowable, and now it says so.** + `ConnectorActionDescriptor` declares `key` / `label` / `description` / + `inputSchema` / `outputSchema` and _nothing_ about whether the action reads or + writes, so `crm.push_opportunity` and `crm.lookup_account` are the same shape to + the runtime. `acted: 0` understates the create; `acted: 1` overstates the + lookup and makes the alert never fire — #4354's original bug, one layer out. + The executor reports `metrics: { unmeasuredEffect: true }` instead, and the run + carries an `unmeasured` tally. Filed #4395 to let a connector declare its effect + kind, which would turn this into a real count. + + **`http` — knowable, and now counted.** The method says it: + `GET`/`HEAD`/`OPTIONS` report a real `acted: 0` (a read cannot write); a mutating + call the upstream accepted reports `acted: 1`; `durable: true` reports `acted: 1` + because the outbox row is a durable effect this run caused. A mutating call that + was _rejected or timed out_ reports `unmeasured` — a 500 can arrive after the + write landed, and claiming zero there would let a run swear it changed nothing + when it had. + + **`script` — deliberately unchanged.** A registered function is contractually + pure ("Data I/O stays on the flow graph — the function itself does no writes"), + so every write it causes is a downstream node counting itself and "reports no + record metrics" is accurate rather than a guess. Nothing _enforces_ that purity, + so a function that writes behind the platform's back under-reports its run — + filed as #4396 rather than papered over here, because a blanket + `unmeasuredEffect` on `script` would suppress the signal on every flow that + calls any function in order to accommodate one contract violation. + + **The alert gains a clause.** `selected > 0 AND acted = 0` becomes + `selected > 0 AND acted = 0 AND unmeasured = 0`, and `sys_automation_run` gains + an `unmeasured_count` column to serve it. Without that third clause the alert + fires on every healthy connector-driven flow. The log line gains + `unmeasured=N` — only when non-zero, since its _presence_ is what a reader must + not miss: `acted=0` on a line that also says `unmeasured=3` means "cannot tell", + not "did nothing". + + `unmeasured` propagates through `subflow` and `map` roll-ups (and through + `creditChildRun` for a child that paused), so a parent whose child dispatched an + uncountable effect knows its own `acted` is incomplete. N uncountable effects in + a child collapse to one flag on the parent's step — the child keeps the real + count in its own run row, and the question this feeds is boolean. + + `FlowRunSummary.unmeasured` is optional and `undefined` is **not** `0`: a run + recorded before this existed did not track uncountable effects at all, and + defaulting it to zero would tell an operator "fully measured" about a run nobody + measured. Same rule the `null` count columns already follow. + + Additive: new optional fields only, no new exports, no execution behaviour + changes. + + Verified: `@objectstack/service-automation` **546 tests / 47 files** (21 new), + `@objectstack/spec` **7193 / 281** (2 new); all 8 `check:generated` gates plus + the seven pure audits (liveness, empty-state, variant-docs, strictness-ledger, + react-conformance, skill-examples, exported-any); `check:nul-bytes` and eslint + clean. + +- 667b83e: feat(spec,automation): publish executor-derived config contracts for the schemaless flow nodes (#4278) + + The five descriptor-schemaless builtins (`decision` / `script` / `subflow` / + `wait` / `connector_action`) deliberately publish no `configSchema`, so their + Studio form lives only in objectui's hand-written `FLOW_NODE_CONFIG` table — + and nothing reconciled that table against the executors. `script` had drifted: + the form offered an `outputVariables` key nothing reads, two `actionType` + options (`sms` / `notification`) that fail every run, a no-op default (`code`), + and could not author the `function` / `inputs` / `outputVariable` path that + works. + + New in `@objectstack/spec/automation` — contract exports only. Unlike their + `builtin-node-config.zod.ts` siblings, which #4277 wired into execute-time + parsing, no engine path `parse()`s node config with these: `script`'s legal key + set depends on `actionType` and `decision` may branch purely on edge + predicates, so a flat parse would either reject valid shapes or check nothing. + Their enforcement is the objectui reconciliation test. + + - `ScriptConfigSchema` / `SubflowConfigSchema` / `DecisionConfigSchema` (+ + `DecisionConditionSchema`) — written from the executors in + `service-automation`, the machine-readable half of the cross-repo + reconciliation objectui's `flow-node-config` test now performs. `wait` and + `connector_action` need no new schema — their contracts are the existing + `FlowNodeSchema` sibling blocks (`waitEventConfig` / `connectorConfig`). + - `SCRIPT_BUILTIN_ACTION_TYPES` (`['email', 'slack']`) and + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` (`'invoke_function'`) — the `script` + executor now builds its dispatch set from the published constant, so the + designer's options, the dispatch set, and the "not a built-in action" + failure message can no longer disagree. + + Undeclared-alias graduation in the same change (Prime Directive #12, the + `map.flow` path): the `subflow` executor's bare `cfg.flowName ?? cfg.flow` + fallback is deleted, replaced by the ADR-0087 D2 conversion + `flow-node-subflow-flow-alias` — a stored `subflow` node authored with + `config.flow` is rewritten to the canonical `config.flowName` at load + (including the `AutomationEngine.registerFlow` rehydration seam). FROM + `config.flow` TO `config.flowName`; one-line fix for hand-maintained sources: + rename the key. + +- 65a3a84: fix(runtime,spec): guard the service-lookup typing with a lint rule — which immediately found the project-membership gate not gating (#4127) + + Batch 4 of the #4127 gate. #4168/#4176/#4202 made a slot lookup return the + slot's contract. Nothing protected that: an `any` annotation on the **result** + switches the checking back off for that call site, silently, with no test + failing and no visual difference from code that has it. Three such sites already + existed and were found by grep — the same unrepeatable sweep this work replaced. + + **The rule** bans `: any` / `as any` on a `resolveService` / `getService` / + `getRequestKernelService` result. Slots with no written contract (`protocol`, + `mcp`, `kernel-resolver`, `scope-manager`) are exempted **by name, centrally**, + in `eslint.config.mjs` — not by inline disables, because `pnpm lint` runs + `--no-inline-config` and ignores those on purpose. The effect is the one worth + having: a deliberate gap is a reviewed line in one file, a careless one is a + build failure, and they stop looking identical in the code. + + **Its first run found a live fail-open.** `enforceProjectMembership` read the + session as `authService?.api?.getSession?.(…)` with no `getApi()` fallback — the + only one of the codebase's three `.api` readers without it. `plugin-auth` + registers `AuthManager`, which has **no `.api` member at all**. So the read + yielded `undefined`, `userId` stayed unset, and the function returned at its + "anonymous — upstream auth will decide" line **before ever querying + `sys_environment_member`**. A signed-in non-member passed the gate, on every + deployment with project scoping on — which is where the flag defaults to true. + Anonymous callers were still denied elsewhere (#2567/#3963), so this was + specifically the signed-in-non-member case. + + The existing test for that gate mocked auth as `{ api: { getSession } }` — the + legacy shape the shipped provider does not have — so it was green throughout. + That is the **fourth** test in this work line found encoding a contract nobody + implements, after batch 1's three `auth.handler` mocks and batch 3's + `status: 'open'`. The new test uses the `getApi()` shape and fails against the + pre-fix code. + + **Also found by the rule**, all the same #4127 shape (implemented, called, + undeclared) and all now declared: `IAuthService` gains `api`, `getApi`, + `isAuthGateActive` and `verifyMcpAccessToken`; `IMetadataService` gains `load` + and `loadDiagnosed`. `getApi`'s return type is the **evidenced subset** — + `getSession({headers})` and the three fields callers read — not a re-declaration + of better-auth's handle, which belongs to that library. + + **And the pattern's real root:** the lookup facade returning `any` was + re-declared in **three** places. Batches 1-3 typed `DomainHandlerDeps` and left + `ActionExecutionDeps` and `resolve-execution-context`'s `ResolveOptions` still + saying `any` — so the copy that stayed untyped was the way around all the + others, and it is where the auth reads lived. All three are typed now. + + Completing the interface: `getRequestKernelService` gets the same overload split + (its one caller resolves the same `objectql` slot the `resolveService` fallback + beside it does, so the two arms of one expression had different types), and + share-links' `getEngine` loses a `Promise` return annotation — a **third** + erasure syntax after `: any` and `as any`, and one this AST rule cannot see. + That residual is documented in the config. + + `getObjectQL` **stays** `any`, deliberately, with the reason recorded: it exists + to reach ObjectQL's surface beyond `IDataEngine` (`registry`, `executeAction`), + which has no contract. Typing it `IDataEngine` would be the comfortable-looking + lie. + + Verified: `@objectstack/runtime` **952 tests / 67 files**, `@objectstack/spec` + **7147 / 275**, plugin-auth **579**, rest **512**; `tsc --noEmit` on spec, + runtime, downstream-contract and all four examples; `pnpm lint` (with + `--no-inline-config`); all nine `check:*` gates. + +- bca935b: fix(spec,runtime): the slot→contract ledger extends past `CoreServiceName`, and `/security` stops passing unvalidated input to the security service (#4127) + + Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176 (`resolveService`). + + Three slots — `security`, `shareLinks`, `objectql` — each had a written + contract, a provider registering them, and call sites already inside the + contract. The only missing link was that the slot name was not a + `CoreServiceName` member, so nothing could connect them and all three sat behind + `as any`. + + **The ledger extends past the enum rather than the enum growing.** The two + answer different questions, and conflating them is what left these untyped: + `CoreServiceName` answers _"what happens at boot when this slot is empty?"_ — it + sits beside `ServiceCriticality` and drives startup orchestration and discovery, + so adding a member changes runtime behaviour and is effectively permanent. The + ledger answers _"what shape occupies this slot?"_ — pure type information. These + three need only the second, so `ServiceSlotContracts extends CoreServiceContracts` + adds them there and `resolveService` keys on `keyof ServiceSlotContracts`. Zero + runtime effect. If one is later promoted to a genuine core service, its entry + moves up and nothing else changes. + + Evidence, as always, before an entry: `plugin-security` registers `security` and + `ISecurityService`'s own doc names that registration; `plugin-sharing` registers + `ShareLinkService`, which declares `implements IShareLinkService`; and `objectql` + is an **alias of `data`** — `packages/objectql`'s plugin registers the _same + instance_ under both names two lines apart, so one object was resolving as + `IDataEngine` through one name and `any` through the other. `protocol` (22 call + sites) and `mcp` have no written contract and stay unmapped. + + **Turning it on found four things, all on the `/security` admin surface:** + + 1. **Request input reached the security service unvalidated.** `?status=` was + `String(query.status)` — any string — handed to a method whose contract + declares exactly three values, and from there into the query's `where` + clause. Not an injection (the `where` is structured, never interpolated), but + `?status=garbage` matched no row and returned an empty list, which reads as + "there are no suggestions" rather than "that is not a status". Now a 400. + + 2. **A test pinned that bug as expected behaviour.** The existing case asserted + `status: 'open'` — not one of the three declared values — reached the service + and returned 200. It proved the delegate carried _a filter_ and nothing about + that filter being a status. Same shape as batch 1's `auth.handler` mocks: + coverage in appearance, a wrong contract in substance. + + 3. **and 4. Two writes could not prove they had a caller.** + `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion` declare + `callerContext: SecurityContext` non-optionally — deliberately, since the + read beside them declares it optional — and the domain passed a possibly- + `undefined` execution context. + + **This was not a live hole**, and the distinction matters: with no execution + context `shouldDenyAnonymous` already denied, because it sees no + `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam + never passes, so it fell through to `return true`. What it never did was + narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is + behaviour-preserving and makes the invariant legible to the compiler and the + next reader. + + The `?status=` rejection is the one **behaviour change**: an unknown status was + a silent empty list and is now a 400 naming the accepted values. The accepted + set is a `Record` keyed on the contract type, so adding a status to the contract + leaves a key missing and renaming one leaves a key excess — either fails to + compile, where a plain array would have drifted silently. + + Verified: `@objectstack/runtime` **945 tests / 66 files** (+8), `@objectstack/spec` + **7141 / 274** (+29), plugin-security **677**, plugin-sharing **225**; + `tsc --noEmit` on spec, runtime, downstream-contract and all four examples; + `pnpm lint`; all nine `check:*` gates. + +- 75b9e51: fix(spec,runtime): a service-slot lookup returns the slot's contract, not `any` — and it immediately found two more gaps (#4127) + + #4127's most valuable item was the one it did not do: "**给这个类别加个 gate**". + The four contract gaps it catalogued were found by a human sweeping the + dispatcher by hand. A sweep is not repeatable, and this one was not complete — + see `/auth` below. + + The root was one line: + + ```ts + // domain-handler-registry.ts + getService(name: string): any; // ← every domain's service handle + ``` + + Against `any`, a domain calling a method its contract declares and a domain + calling a method nobody declares typecheck identically. That is what let #4087 + ship a `/storage` handler passing two arguments no implementation takes, and + what hid #4127's four. + + **`CoreServiceContracts` — the slot → contract ledger.** `CoreServiceName` named + the slots and `contracts/*` described them; nothing connected the two. It does + now, and `getService(name: K)` resolves through it, so a call outside the + contract is a **compile error at the call site**. + + An entry is a claim, so entries are only made where the binding is evidenced — + by the provider that registers the slot (`service-storage` → `file-storage`, + `objectql` → `data`, whose own comment reads "ObjectQL implements IDataEngine"), + or by dispatcher work that proved it (#4143/#4150 for `automation`, + `notification`, `i18n`). **`ui` is deliberately unmapped**: the slot exists and + `domains/ui.ts` serves it, but no `IUiService` was ever written. An unmapped slot + resolves to `unknown`, not `any` — it must be cast deliberately, so the gap stays + legible instead of looking checked. + + **Two findings, within minutes of turning it on:** + + **`/auth` called a method that does not exist.** `domains/auth.ts` probed + `authService.handler(request, response)`. `IAuthService` declares + `handleRequest(request): Promise`; `AuthManager` implements exactly + that and has no `handler`. The probe was false on every deployment — #4143's dead + `automation.trigger` again. **#4127's manual sweep never mentions `/auth`**, + neither in its gap list nor in its "扫干净的" list: the file the compiler flagged + first is the one the human pass skipped entirely. + + Not a live hole: the Hono adapter calls `handleRequest` itself and only falls + through to the dispatcher when no usable auth service answered, so nothing was + served by the mock in that deployment. But reading the contract makes the branch + reachable for the first time — a host calling `handleAuth` directly WITH an auth + service registered used to get `mockAuthFallback`'s `mock_` session instead + of real authentication, and now gets the auth service. + + **`POST /analytics/sql` invoked an optional method unguarded.** `generateSql?` is + optional on `IAnalyticsService` — unlike `query`/`getMeta` beside it — and the + call had no probe, so a provider without it answers a 500 from `TypeError` + instead of saying the capability is absent. service-analytics implements it, + which is why nothing noticed; the contract permits a provider that does not, and + this slot is multi-provider by design. It answers `handled: false` now, the same + 404 the file's entry gate already gives for absent analytics capability. + + **`isServiceServeable` is a type guard now** (`svc is NonNullable`). Every + domain already calls it first on a resolved slot, so one predicate narrows away + the `undefined` for the whole handler body — the null check and the capability + check were always the same check. + + **The test-side hole, closed for this batch.** #4127's last section predicted it: + the mocks are written to what the handler wants, so handler and test agree with + each other and with no implementation. **Three** tests across two files mocked + `{ handler }` for auth — including one whose entire subject was the _resolution + path_, so it proved the lookup worked and nothing about the call. `ContractMock` + (`Partial>`) now guards the mocks: keys are checked + against the contract, signatures deliberately left `unknown` so `vi.fn()` does not + force everything back to `as any`. The automation mock's `trigger` — genuinely + not on the contract — stays as an explicit, labelled negative control outside the + checked literal, because a test asserting the route _never_ calls it is the point. + + Nothing is renamed and no runtime behavior changes except the two fixes above. + The 12 domains not calling `getService` are untouched; `resolveService` (which + also takes non-`CoreServiceName` names like `protocol` and `objectql`) is + deliberately left for a later batch rather than widened here. + + Verified: `@objectstack/runtime` **933 tests / 65 files**, `@objectstack/spec` + **7095 / 272** (6 new, pinning the map against the enum in both directions), + service-automation **457**, service-analytics **413**, service-messaging **137**, + service-i18n **62**, adapter-hono **73**; `tsc --noEmit` on spec, runtime, + downstream-contract and all four examples; `pnpm lint`; and all nine + `@objectstack/spec` `check:*` gates — clean. + +- 6f23667: **`config.storage` is not a stack key, and an undeclared top-level key now says + so instead of vanishing (#4167).** + + `os serve` read `config.storage` and forwarded it to `StorageServicePlugin`. + It could almost never arrive: `ObjectStackDefinitionSchema` does not declare + `storage`, and is not `.strict()`, so `defineStack` — which every documented + authoring path and every compiled artifact goes through — strips the key before + `serve` runs. The one combination that reached the branch (a bare-object config + on the config-boot path) then carried the `driver`/`root` spelling the plugin + does not read either, so it did nothing there too. + + The result was one authoring key that worked on a single unreachable-in-practice + path and disappeared silently everywhere else. A host writing + `storage: { driver: 's3', … }` believed it had configured S3 and got local disk. + + - **`serve` no longer reads it.** `resolveStorageCapabilityArg` takes only the + env root; the production warning stops naming `config.storage` and names the + two channels that work — `OS_STORAGE_*` and Setup → Settings, the latter being + the one with proper credential handling. + - **The undeclared-key lint now covers the stack's own top-level keys.** New + `lintUnknownStackKeys(rawStack, stackSchema)`, wired into `defineStack`, + `os validate` and `os compile` beside the existing walker. `storage` gets a + prescriptive entry naming both channels and why a stack definition is the + wrong home for a credential — it would commit it to git and to any published + artifact. An ordinary misspelling still gets the edit-distance suggestion + (`datasource` → `datasources`). + - **`os migrate files-to-references` shares the resolver.** It built + `{ driver: 'local', root }` — the same dead keys — so its adapter used + `./storage` while the server writes under `.objectstack/data/uploads` since + #4096. That command reconciles what records claim against what storage holds, + so a disagreeing root reconciled against the wrong tree. + + **`onEnable` is exempt, and the exemption has one owner.** `onEnable` is a + function, so `ObjectStackDefinitionSchema` cannot declare it and + `dist/objectstack.json` cannot carry it — but it is not lost: `AppPlugin` calls + it off the authored bundle, and the artifact-boot path grafts it back (#4095). + "Not declared" and "dropped at load" are different claims, and this is the + surface where they come apart. New `STACK_RUNTIME_MEMBERS` in `@objectstack/spec` + names the members the runtime honours off the bundle; the lint treats them as + declared, and the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is now **derived** from it + rather than restating it, so the list that decides what gets grafted and the + list that decides what the lint stays quiet about cannot drift. `onDisable` is + deliberately not on it — nothing calls it, so a value written there really does + go nowhere and the lint should say so. + + Additive: `lintUnknownAuthoringKeys` keeps its signature. The new pass is a + separate export rather than a fold into that walker for two reasons. The walker + iterates metadata COLLECTIONS, so a stack whose only mistake is at the envelope + level — no objects, no pages, nothing to iterate — walks clean; and the stack + schema has to be INJECTED, because `stack.zod.ts` imports the lint module and + importing back would close a cycle. A separate export keeps that requirement + visible: a call site either asks for the coverage or does not, and its absence + shows up in a diff. An optional parameter would be the same silent-loss shape + this rule family exists to report. It follows the walker's posture rule — only a + schema that STRIPS unknown keys is linted, so if the stack schema ever graduates + to `.strict()` the parse takes over and this goes quiet. + + Verified end to end: authoring `storage:` through `defineStack` warns at load, + and `os compile` reports it for configs that skip `defineStack`. + + Nothing is being taken away that worked. `storage` was never in the schema, is + not documented anywhere, and has no consumer in `objectstack-ai/cloud` (checked). + Whether the platform should eventually grow a real in-stack storage declaration + is a separate question — if so it should follow `datasources`, which solves + credentials by referencing `sys_secret` rather than inlining them, and that + deserves an ADR rather than a resurrected undeclared key. + +- 5d21a48: feat(spec,metadata-protocol,metadata,objectql,service-automation): stored metadata replays the full conversion chain at rehydration (#3903) + + Every mechanism the platform has for evolving the metadata contract — schema + transforms, the ADR-0087 D2 conversion layer, the D3 migration chain, the + protocol-17 tombstones — operated on **authored source** only. Metadata **at + rest** (`sys_metadata` rows written by Studio or the runtime authoring APIs) + was rehydrated unparsed and unconverted, so the authored and stored contracts + silently diverged: a pre-17 row carrying `conditionalRequired` or `execute` + read as whatever each ad-hoc consumer happened to do with it. + + **New spec primitive — `applyConversionsToStoredItem(type, item, options?)`** + (exported from the package root). Wraps one stored item of a given metadata + type and replays the **full** conversion chain over it — `retiredFromLoadPath` + entries included, because retirement is an _authoring-surface_ event: the + window exists to teach a live author, and a row at rest has no author to + teach. Idempotent, never throws, never validates. + + Wired at every stored-row rehydration seam: + + - `metadata-protocol`: `loadMetaFromDb`, `getMetaItems` (active + draft + preview), `getMetaItem` (active + draft), `getMetaItemLayered`, and + `duplicatePackage` (a copy re-saves through the schema gate, so legacy + sources now duplicate successfully — and the copy is canonical). + - `metadata`: the DatabaseLoader's live-row reads (`load` / `loadMany`). + History reads stay verbatim — history records what was written. + - `objectql`: the authored-action / authored-hook direct table reads, so + runtime-authored actions stored with the removed `execute` alias dispatch + via `target` again. + - `service-automation`: `AutomationEngine.registerFlow` now passes + `includeRetired` — stored flows keep canonicalizing after their conversions + graduate out of the load window. (The generic metadata seams deliberately + skip `type: 'flow'`: flow conversions carry the open-namespace conflict + guard, which needs this engine's live executor registry.) + + **Boot hydration diagnoses instead of shrugging.** `loadMetaFromDb` now + returns `{ loaded, errors, invalid }`: each row is validated against its + type's spec schema _after_ conversion, and a genuine contract violation is + counted and warned with a stable `[metadata_spec_invalid]` marker — but still + registered, deliberately: refusing at boot would unhook live tables and make + the row unlistable and unfixable in Studio. The write path (`saveMetaItem` → 422) and the read-side `_diagnostics` envelope remain the enforcing gates; the + `SchemaRegistry.registerItem` validation hook is now documented as exactly + that diagnostic. + + **Retired accommodation.** With the chain running on every stored read path, + the rule-validator's `requiredWhen ?? conditionalRequired` fallback — kept in + #3883 with a retirement promise that had no mechanism — is deleted. If you + call `evaluateValidationRules` directly with raw legacy field definitions, + convert them first (`applyConversionsToStoredItem('object', def)`) or author + `requiredWhen`; the platform's own read paths already hand you canonical + shapes. + +- b3a3d83: feat(spec): a shared temporal conformance matrix, and the `$between` gap it found (ADR-0053 D-A3, #4081) + + `@objectstack/spec/data` gains `TEMPORAL_ROWS` and `TEMPORAL_CASES` — the + single set of temporal filter cases every backend is checked against, the twin + of the existing `FILTER_LOGIC_CASES`. Five backends consume it and assert **row + results**: `driver-sql` (and, through the live-dialect CI job, real Postgres and + MySQL), `driver-memory`, `driver-mongodb` (real MongoDB), the analytics preview + evaluator, and `formula`'s RLS write-side `check`. + + This is the regression backstop ADR-0053 D-A3 has asked for since 2026-06 and + the last of its decisions to be actioned. Four separate incidents — #3650, + #3773, #3777, #4047 — were each found by a human by accident, and each left a + suite proving only its own issue against its own fixture. Nothing held the + backends to one standard, so the fifth divergence had nowhere to fail. + + **`service-analytics` — a real fix the matrix found on its first run.** The + draft-preview evaluator had no `$between` case, so it fell through to its + permissive `default` and matched **every** row: a drafted dashboard carrying a + range filter charted the entire dataset, then changed its numbers at publish — + the exact continuity the preview exists to provide. It now evaluates + `$between`, sharing the upper-bound helper with `$lte` so the whole-day + calendar-day rule (#3777) applies to a range's max as well. + + Also recorded (ADR-0053 D-A3.1): `$gt` with a bare-day comparand on a + `datetime` column cannot agree between typed and type-blind backends, and the + gap is irreducible without field types. It is asserted in the shared matrix on + `date` only, with the `datetime` cell left to the typed drivers' own suites, + rather than papered over. + +- 7a55913: feat(spec): the temporal conformance matrix gains its relative-token axis (ADR-0053 D-A3, #4081) + + The matrix (#4098) landed with the token axis documented as still open — the + cases took resolved comparands, while authors actually write `{today}` / + `{90_days_ago}` / `{current_month_end}`, and nothing proved the resolved + token reaches the same rows on every backend. Closed here: + + - `TemporalCase` gains `tokenFilter?` (the same filter spelled in tokens) and + `dateRange?` (the analytics `timeDimensions.dateRange` spelling of the same + window — the surface #3650 broke), plus the pinned instant `TEMPORAL_NOW` + consumers hand to `resolveFilterTokens`. A resolver drift and an evaluator + drift are now distinguishable at a glance: the literal spelling fails for + one, the token spelling for the other. + - `TemporalRow` gains `writerForm` (`wire` ISO string vs `native` `Date`), so + driver consumers seed genuinely mixed writer populations through their own + `create()` — the exact column shape that produced #4047 (D-E4). + - New rows/cells: a pre-epoch row (negative epoch ms, the #3773 family), and + the `date` equality/`$in` cases — #1874's original `date == today` shape. + - Two more sweeps consume the table: a `driver-sqlite-wasm` conformance test + pinning the inherited SqlDriver seam, and a legacy-storage sweep in + `driver-sql` running every case over an un-backfilled mixed epoch/naive + column through the read-repair path. + +- 35accbf: feat(spec): promote the temporal storage hooks onto the IDataDriver contract (ADR-0053 D-A2) + + `temporalFilterValue` and `temporalFilterColumnSql` — the pair that closed + #3912's storage-form drift — were duck-typed: analytics probed + `typeof driver.x === 'function'` against a locally-invented interface, and + nothing at the type level said a driver must implement both or neither. The + lesson of #3912 is precisely that coercing the comparand without normalising + the column reintroduces half the bug, so a driver implementing one hook alone + would silently regress. + + Both are now optional members of `IDataDriver` + (`@objectstack/spec/contracts`), documented as a pair with "absent = identity" + semantics for drivers whose storage form is the wire form (memory, mongo). + `SqlDriver implements IDataDriver`, so its signatures are compile-checked from + here on; analytics derives its driver seam by `Pick`-ing the contract instead + of a local duck type. Runtime `typeof` guards remain — that is the correct way + to consume an optional contract member — but the shape they guard now has one + authoritative definition. + + No runtime behaviour change. ADR-0053 D-A2 is recorded as resolved. + +- 6038de7: feat(spec,drivers): the temporal conformance matrix gains its `Field.time` axis — and `time` finally gets a storage form off SQL (ADR-0053 D-A3.2) + + `@objectstack/spec/data` gains `TEMPORAL_TIME_ROWS` / `TEMPORAL_TIME_CASES`, + the wall-clock half of the shared matrix. A time gets its own table rather than + a third `kind` on the existing one because it shares no comparand vocabulary + with the other two: no relative token resolves to a wall clock, and the + bare-day whole-day rule (#3777) must **not** reach it — which the table now + asserts rather than assumes, since "the rule leaked into the wrong field type" + is exactly what a conformance matrix is for. The fixture is a business day + carrying the boundaries #3994 measured: both window edges, the pair straddling + the millisecond-suffix width change, midnight and `23:59:59.999`. + + **The axis found a real gap on its first run.** ADR-0053 D-C gave `Field.time` + a canonical form on every SQL dialect, but `driver-memory` and + `driver-mongodb` were never extended — both declared + `TemporalFieldKind = 'datetime' | 'date'`, so a `time` column was never + classified and never coerced. It therefore held whatever each writer produced, + and both stores compare across types by bracket: a text bound matched no + `Date`-written row, in either direction, for every operator. Measured on + `driver-memory`, **8 of the 9 shared cases** returned only the text-written + half — a business-hours window answering `[d_mid, f_close]` instead of + `[c_open, d_mid, e_mid_ms, f_close]`. This is #4047's failure one field type + over, and it survived #4047 because that work extended `datetime` and `date` + without revisiting `time`. On mongo it was also a documentation failure: that + module's canon table has listed `time` as `HH:MM:SS[.fff]` text since #3994, + and nothing implemented it. + + Both drivers now carry `storageTimeValue`, mirroring the SQL + `canonicalTimeOfDay`: `HH:MM:SS`, `.fff` only when the milliseconds are + non-zero, a `Date` / epoch / full-timestamp folding to its **UTC** time-of-day + (never the host's), and totality — an out-of-range wall clock like `'25:00'` + passes through rather than being silently rewritten. Text on both, mongo + included: a wall clock is not an instant, so a BSON `Date` would invent a + calendar day and a zone the author never wrote. + + If you have existing `time` data on either driver, values written as `Date` + objects converge to canonical text on their next write; reads of un-migrated + documents are unchanged. Filters were already unable to reach the mixed half, + so no query that worked before stops working. + +- e4c2dc8: Order temporal operands correctly when one side is a JS `Date` on the two + type-blind filter backends (ADR-0053 D-A3 / #4191). + + `utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data` + (re-exported from `@objectstack/core`): it reads the UTC instant a temporal + operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a + bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a + zone-naive one being UTC, per D-B2) — and returning `null` for everything + else, notably a bare wall clock, which denotes no instant. + + Both type-blind evaluators now use it to compare a `Date` against wire text, + which JS relational operators cannot do: `<` and friends coerce with hint + `number`, so the `Date` becomes its epoch and the string becomes `NaN`. + + - `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped + every `Date`-valued row in 10 of the 16 shared conformance cases. The + post-image is the caller's raw write payload, so an SDK write of + `new Date()` hit this directly, and fail-closed turned it into a **denied + write**. + - `service-analytics`' preview evaluator diverged on the same 10 cases in + BOTH directions, because `String(new Date())` sorts after every `'2026-…'` + comparand — a drafted chart both lost rows and gained ones, then changed + its numbers at publish. Rows from a mongo-backed dataset arrive as BSON + `Date`s, so this was reachable in normal use. + + Comparisons that did not involve a `Date` are unchanged. + +- 1bd2795: feat(spec,lint): the `ui` vocabularies admit what the renderers implement, and derive instead of restating (objectui#2945) + + Additions-only follow-up to the vocabulary audit + (objectstack-ai/objectui#2901, #2945). Nothing here narrows a vocabulary, so no + already-stored metadata changes meaning — three of the four `ui/` enums that had + drifted from what is actually implemented, plus the fork that drift had made + invisible. + + **`ChartTypeSchema` admits `combo`.** The taxonomy could not name the one chart + family the rest of `chart.zod.ts` is written for: `ChartSeriesSchema.type` + exists to override a series' type — its doc comment literally says _"combo + charts"_ — and `ChartSeriesSchema.yAxis` binds a series to the left or right + axis, which is only meaningful for mixed marks. objectui's renderer draws it + distinctly (mixed bar/line/area on dual axes, per-series type) and had to carry + `combo` in a local fork of this list, whose own comment claimed to mirror it. + + **`WidgetActionTypeSchema` is `ActionType`.** The two disagreed by one member, + `form`, and the disagreement was backwards: a dashboard header or widget action + button dispatches through the same `ActionRunner` that implements `form` — + objectui's `DashboardRenderer` deliberately routes everything except a raw `url` + into it, so a `flow` header action works (#3528). The narrower enum therefore + rejected at validation exactly what the shared dispatcher then executes. + Derived, so the next type the runner implements needs one edit, not two. + + **`ListChartConfigSchema.chartType` is `ChartTypeSchema.extract([...])`.** Same + five members as before — a de-duplication, not a widening. A member renamed in + the taxonomy now fails at build time instead of leaving a second list quietly + disagreeing. + + **`@objectstack/lint`'s chart-family set is derived from the taxonomy.** + `validate-widget-bindings` decides which widgets need a `chartConfig` measure + mapping from a hand-written list of families, and its omissions fail in the + worst direction: an unlisted family reads as _"not a chart"_, so a widget + missing its mapping **passes** validation. `combo` was exactly that case — + verified by pinning the old list back, where a `combo` widget with no + `chartConfig` produced zero findings. The set is now the taxonomy minus an + explicit `MEASURE_EXEMPT_CHART_TYPES` (single-value and tabular families), so a + family added to the spec is covered without editing the rule. + + Guards: `packages/spec/src/ui/vocabulary-derivation.test.ts` asserts both + derivations still hold (a restated list fails silently — it keeps validating, + just not what the other list says), and the lint suite now walks every + multi-series family in the taxonomy rather than a list of its own. + + A third ratchet already existed and did its job: `app-showcase`'s coverage test + requires a gallery widget for every distinctly-renderable `ChartType`, and it + failed the moment `combo` was admitted. The Chart Gallery dashboard now + demonstrates it — a task count as bars on the left axis, an average as a line on + the right, which is the configuration `series[].type` / `series[].yAxis` exist + for. + + `ActionType` deliberately does **not** gain `navigation`, which the audit + suggested. `ActionRunner.executeNavigation` is a strictly weaker + `executeUrl` — no `${param.X}` interpolation, no `apiBase` promotion, no + `openIn` — differing only by a `replace` option, and its one live producer is + the SDUI `element:button` `action` prop, which `ElementButtonPropsSchema` does + not model at all. Promoting the name would add a second spelling of _navigate_ + to a closed authorable vocabulary (members cannot be removed later) without + closing the gap that actually exists. Tracked separately. + + Verified: `@objectstack/spec` **6944 tests / 267 files**, `@objectstack/lint` + **544 tests / 37 files**, both green; `tsc --noEmit` clean on both. + +- 8186a70: feat(spec,cli): report the authored object/field keys that get silently dropped (#3786) + + `ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they + do not declare **parses clean and is stripped on the way to storage**. No error, + no warning — the author configured something and it simply is not there. That is + the ADR-0104 failure class the `FieldSchema` prune tombstone already describes in + prose, and #4120 found five live instances of it inside `@objectstack/spec` + itself: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had + been rendering in Studio for releases while saving nothing. + + **New rule — `lintUnknownAuthoringKeys` (advisory).** Every authored key an + object or field sets that its schema does not declare is now reported, naming the + path, the key, and what to do about it: + + ``` + defineStack: objects.crm_case.fields.owner.pii: 'pii' is not a declared field key, + so its value is dropped at load — the `dataQuality` governance family was pruned + in 2026-06 as dead in both layers — it enforced nothing. + defineStack: objects.crm_case.capabilities: 'capabilities' is not a declared object + key, so its value is dropped at load — did you mean 'enable'? + ``` + + Two guidance tables carry the difference between a **rename** (`formula` → + `expression`, `cascadeDelete` → `deleteBehavior`, `capabilities` → `enable`, …) + and a **retirement** with no successor (`pii`, `indexed`, `encrypted`, + `startingNumber`, …). A retirement deliberately suppresses the edit-distance + fallback: `pii` is three edits from `min`, and "did you mean min?" reads as real + advice while being nonsense. Plain typos still get the fallback (`requred` → + `required`). Every entry was found in the wild, and a test asserts each rename + target is a key the schema really declares — so the advice cannot rot into + pointers at keys that no longer exist. + + **It never rejects.** Making these two schemas strict is the destination — the + enforce side of ADR-0049, and the tier programme #4001 began on the flow and + permission schemas. But `object` and `field` are the two most-authored surfaces + in the protocol, so flipping them rejects metadata that parses today: a migration + event for every consumer, and one that deserves to be scheduled on evidence + rather than guessed at. This produces that evidence and costs nobody a migration. + + Wired into every layer that performs the discard, all **pre-parse** (the parse is + what eats the key, so after it there is nothing left to report): + + - **`defineStack`** — warns on the console, once per distinct path, in strict + _and_ non-strict mode, since the key is dropped either way. + - **`os validate`** — a non-blocking warning, and included in `--json` output + rather than computed and discarded. + - **`os build` / `os compile`** — the same non-blocking warning. `defineStack` + already covers configs authored through it; this catches the ones that skip it + (a plain object default-export, `strict: false`), which would otherwise emit an + artifact with the key quietly gone. + + Verified against the three first-party example apps (`app-todo`, `app-crm`, + `app-showcase`): all clean, no false positives. + + New exports from `@objectstack/spec` (root and `/data`): `lintUnknownAuthoringKeys`, + `formatUnknownAuthoringKey`, `FIELD_KEY_GUIDANCE`, `OBJECT_KEY_GUIDANCE`, and the + `UnknownAuthoringKeyFinding` / `AuthoringKeySurface` types. No authoring change is + required by this release: metadata that loaded before still loads, unchanged. + +- a329cca: The unknown-authoring-key lint now descends into nested metadata, not just each + item's top level. + + `lintUnknownAuthoringKeys` (#3786) reported unknown keys on each metadata item's + top level plus one hard-coded hop into `object.fields`. That left **227 + strip-mode objects** nested below those roots reporting nothing — and they are + concentrated exactly where authoring volume is: `object` 71, `view` 49, `page` + 24, `dashboard` 18, `agent` 16, `mapping` 14. Those sites were both silently + dropping keys and contributing nothing to the evidence base the v18 strict + close-out is meant to be scheduled on. + + The walk now follows the authored value alongside its schema through nested + objects, arrays and records. Posture rules are unchanged, so the lint still never + double-reports what the parse already handles: + + - `strict` → silent (the parse is loud on its own) + - `passthrough` → silent (the key legally survives) + - `strip` → reported, and the descent continues through it + + Unions descend only when the authored value picks a branch unambiguously — a + discriminated union whose discriminator the author actually wrote. Otherwise the + merged posture applies at that level and the walk stops, because guessing a + branch would invent findings against a shape nobody wrote. + + `object.fields` still reports as the `field` surface with its curated guidance, + now via an explicit override table rather than a special case — so its own nested + sites (`fields.*.options[]`, …) are covered too. + + Still non-blocking: these are warnings from `defineStack()`, `os validate` and + `os compile`, exactly as before. Expect existing projects to surface more of + them — each one is a key that was already being dropped, now visible. + +- 6eec18c: Hooks and datasources reject unknown keys (#4001 data step). + + Closes the last two entries in the strictness ledger that still carried a + provisional classification. Both were confirmed authorable the same way: they sit + in `BUILTIN_METADATA_TYPE_SCHEMAS`, so one shape backs `defineStack()` parsing, + `/api/v1/meta/types/:type`, and the Studio form. + + Now strict: + + - `HookSchema` + its `retryPolicy`, and both hook-body branches + (`ExpressionBodySchema`, `ScriptBodySchema`). A misspelt `capabilities` + stripped to the empty default and the sandbox threw at invocation time instead + of at parse; a misspelt `timeoutMs`/`memoryMb` silently downgraded the body to + the enclosing hook's limits. + - `DatasourceSchema` + `pool` / `healthCheck` / `ssl` / `retryPolicy`, + `ExternalDatasourceSettingsSchema` + its `validation` block, + `DatasourceCapabilities`, and `DriverDefinitionSchema`. + + Deliberately still tolerant: + + - `HookContextSchema` and its `session` / `provenance` / `user` blocks — the + runtime shape the engine hands a handler. Strictness there would turn an + engine-internal enrichment (as `provenance` was in #3712) into a breaking + change for anyone parsing a context they were given. + - `datasource.config` and `readReplicas` — per-driver by construction; the + driver's own `configSchema` validates them. + + Errors are self-fixing: connection keys written one level too high (`host`, + `port`, `filename`, `url`, …) are prescribed into `config`; a top-level + `password` is pointed at `external.credentialsRef` rather than merely relocated; + and the two near-miss spellings that cross between shapes carry aliases + (hook-level `timeout` vs body-level `timeoutMs`; hook `retryPolicy.backoffMs` vs + datasource `retryPolicy.baseDelayMs`). + + Note for anyone reading the earlier steps: strictness does not change the + published JSON Schema. `build-schemas.ts` converts with `io: 'output'`, where zod + emits `additionalProperties: false` for `.strip()` objects too — verified by + regenerating both ways (`Datasource.json` is byte-identical). The JSON Schema was + already advertising `additionalProperties: false` while the parse silently + dropped keys; this aligns the parse with the published contract. + +- 0166bd5: fix(spec,drivers): the view filter vocabulary and the AST vocabulary now agree (#3948) + + `VIEW_FILTER_OPERATORS` (`ui/view.zod.ts`) is what an author may declare on a + `ViewFilterRule`. `VALID_AST_OPERATORS` (`data/filter.zod.ts`) gates + `isFilterAST()`, which decides whether a filter is parsed into a query at all. + They disagreed on **8 of 19** members: `equals`, `not_equals`, `greater_than`, + `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after`. + + An author could declare any of them, `ViewFilterRuleSchema` validated them, + `defineStack` accepted them — and then `isFilterAST()` refused the filter, the + protocol passed the array through unconverted, and the driver could not apply it. + Six of the eight were reachable only in theory because ObjectUI's adapter alias + table happened to translate them; the safety of the query path was resting on a + hand-written table in another repository being complete, and for `before`/`after` + it wasn't. + + **`AST_OPERATOR_MAP` is now the single source of truth.** `VALID_AST_OPERATORS` + is derived from its keys rather than restated, so an operator can no longer be + accepted by the gate without also having a lowering — the two were separate + hand-written lists that happened to agree, with nothing enforcing it. The map + gained the eight canonical view spellings plus the squashed/short forms stored + metadata carries (`notequals`, `greaterthanorequal`, `eq`, `gt`, …). + + **New export `canonicalAstOperator(op)`** folds every accepted spelling of one + comparison onto a single infix form. Both drivers now call it instead of growing + private alias lists, which is what let them accept different vocabularies. + `like`/`ilike` are deliberately not folded onto `contains`: driver-sql passes them + to SQL verbatim, so folding would silently wrap the value in `%…%`. + + Widening only — no spelling was removed, so no stored filter stops validating. + A filter that previously produced an error (after #4029) or was silently dropped + (before it) now compiles. `filter-view-operator-parity.test.ts` asserts every + `VIEW_FILTER_OPERATORS` member and every `VIEW_FILTER_OPERATOR_ALIASES` key has a + lowering that is a real `$`-operator rather than the `$${op}` fallback, so the + next operator the view layer gains fails a test instead of a query. + +### Patch Changes + +- 6a67d7a: feat(lint): L2 action-body writes to undeclared fields warn at author time (#4271) + + The write-set lint that #4305 gave L2 hook bodies now covers the other surface + that carries one. An action body is the same artefact: the same + `HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in + `actionBodyRunnerFactory`, run in the same QuickJS sandbox. So it fails the + same way — `ctx.api.object('crm_deal').update({ stag: 'won' })` inside an + action reaches the driver unfiltered, and the outcome splits by driver: on SQL + the stray column fails the whole call with a driver-level error far from the + authoring site, and on a schemaless driver the stray key is persisted. Half + the surface was still blind. + + **New rule — `action-body-write-unknown-field` (advisory).** Wired into + `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` all + report it; it never blocks a build. Both places the runtime reads actions from + are walked — top-level `actions` and `objects[].actions` — and a + `defineStack`-merged action, which lives in both, is reported once at its + authored path. That dedupe is by VALUE (bound object + name + body source), not + by object identity the way `collectBundleActions` can afford: the suite runs on + the schema-PARSED stack, and parsing rebuilds every node, so the two copies + arrive as distinct objects that are merely equal. An identity check passes a + shared-reference unit fixture and then reports the showcase app's one warning + twice — which is exactly what it did before the end-to-end run caught it. + + **Only the `ctx.api` write family carries over, and that is the point.** An + action's `ctx.input` is its PARAMS bag (`input: unwrapProxyToPlain(actionCtx +?.params)`), not a record, so resolving those names against object fields would + flag every correctly-named parameter — a pure false-positive machine, and a + false positive kills an advisory lint. `ctx.record` is not a write surface + either: the runner hands the body a plain snapshot and never writes it back, so + `ctx.record.x = …` is discarded for _declared_ and undeclared fields alike — + a different defect from "the unknown column vanishes", and flagging only its + undeclared half would imply the declared half persists. + + So the rule ships a declared **partition** of the shared + `HOOK_BODY_WRITE_PATTERNS` rather than a second ledger: + `ACTION_BODY_WRITE_PATTERN_IDS` (today: `api-crud-literal`) and + `ACTION_BODY_WRITE_EXCLUSIONS` (`input-property-assign`, + `input-object-assign`), each exclusion carrying its reason. The two halves are + tested to cover the shared ledger exactly, so a fourth pattern landing on the + hook side fails this rule's test until someone classifies it — silence is not a + decision. Every applicable pattern is additionally proved end-to-end through + the full validator (prefilter, pattern filter and field check included), and + every exclusion is proved to be about applicability rather than an + unextractable shape: the shared extractor still sees it, and this rule still + reports nothing for it. + + One extractor, one field index, one implicit-field set, shared with the hook + rule rather than copied. The action rule is the same check on the other body + surface, so a second copy of `IMPLICIT_FIELDS` would drift exactly the way the + five hand-copied system-field lists #4330 collapsed did. + + The lint stays off the kernel boot path, and lands one notch tighter than the + hook side: the only applicable pattern is rooted at `ctx.api`, so an action + body that never mentions it does not even parse, let alone load the ~9 MB + TypeScript compiler. Guarded by `lazy-deps.test.ts`. + + `@objectstack/spec`: `ScriptBodySchema` and `ActionSchema.body` now point at + the action-side rule and spell out that `ctx.input` (params) and `ctx.record` + (a discarded snapshot) are not record-write surfaces — doc comments only, no + schema or generated-artifact change. + +- 0ecc656: feat(lint): an action body's discarded `ctx.record` write warns at author time (#4345) + + `#4344` deliberately left `ctx.record` alone, and said why: an action's + `ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that + `boundActionHandler` never writes back — the hook path's + `applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …` + is discarded for **declared and undeclared fields alike**. Reporting that + through the unknown-field rule would have been actively wrong: flagging only + the undeclared half implies the declared half persists, which is the false + completion this rule family exists to stop manufacturing. It needed its own + finding, and now has one. + + **New rule — `action-record-write-discarded` (advisory).** + + **It is not "flag every `ctx.record.` assignment"** — that would be a + false-positive machine, because mutating the snapshot to build a payload is a + legitimate idiom: + + ```js + ctx.record.stage = "won"; + await ctx.api.object("crm_deal").update(ctx.record); // the write is LIVE + ``` + + So the finding requires the write to be **provably dead**: reported only when + `ctx.record` never escapes the body as a value. Property reads + (`ctx.record.id`) do not rescue a write and do not suppress the finding; + handing the object to anything — an argument, an assignment RHS, a spread, a + return — does. Aliasing (`const r = ctx.record`) reads as an escape, which is + the safe direction: it costs a missed finding, never a false one. + + Truthiness and type tests are **not** escapes, and that distinction is what + makes the rule fire on real code rather than almost never. Running it against + the showcase app is what surfaced it: `mark_done` opens with + `ctx.recordId || (ctx.record && ctx.record.id)`, the defensive idiom action + bodies are actually written with, and counting that guard as an escape silenced + the finding on the one body in the repo that had a record write. A test reads + the reference and yields a boolean — or, for `&&`/`||`/`??`, yields the left + operand only when it is falsy, which is null or undefined and persists nothing. + Only the LEFT operand is a test: `x || ctx.record` really does evaluate to the + object, and still escapes. + + **One suite member, two rule ids.** Both findings fall out of one parse of one + source on one surface, so `validateActionBodyWrites` reports both rather than + `REFERENCE_INTEGRITY_RULES` growing a second member that would parse every + action body again to say two things about the same walk. The alternative — + hand-wiring it into the three CLI commands — is the drift that suite exists to + end, and `validateReadonlyFlowWrites` is the standing proof: wired into + `validate` and `compile`, never into `lint`. The trade-off is written down at + both ends rather than left to be rediscovered. + + **The ledger ratchet fired, as designed.** `record-property-assign` joins the + shared `HOOK_BODY_WRITE_PATTERNS` — the extractor's shape inventory, not any + one rule's — and both existing consumers had to classify it before it could + land. That was not cosmetic on the hook side: a `record-property-assign` write + carries no `object`, and `validateHookBodyWrites` branched on exactly that to + mean "a `ctx.input` write", so the new shape would have been reported as _"the + hook writes 'stage' to its input"_. The hook rule now declares its own + consumed subset (`HOOK_BODY_WRITE_PATTERN_IDS`) and its exclusion with a + reason — a hook sandbox context has no `ctx.record` at all + (`buildSandboxContext` never sets it), so the expression throws at run time + rather than silently no-op'ing, and a loud failure is not an advisory rule's + business. + + `extractHookBodyWriteSet` is the new one-parse entry point, returning the + writes plus the `ctxRecordEscapes` signal; `extractHookBodyWrites` stays as a + thin projection of it. + + **Boot path.** The action gate's prefilter widens from `api` to `api`-or- + `record`, so a body reaching neither still never loads the ~9 MB TypeScript + compiler. `lazy-deps.test.ts` pins it — and its header and two case names, + which still claimed every lazy dep waited on "a react page", now say which + trigger each one pins (typescript has also been loaded by the hook-body gate + since #4271). + + `@objectstack/spec` / `@objectstack/runtime`: `ScriptBodySchema`, + `ActionSchema.body` and `ScriptContext.record` now state that + `ctx.api.object(...)` is the only path that persists anything, and that + `ctx.record` is read-only in effect. Doc comments only — no schema or + generated-artifact change. Whether the runtime should instead refuse or honour + a record write stays open on #4345. + +- 06772eb: docs(spec): state what the action `requiredPermissions` server gate does NOT cover (#3923) + + `ActionSchema.requiredPermissions` documented itself as a dual-surface gate whose + server half is "the source of truth": declare once, get a 403 on the server and a + hidden button in the UI. An app author reasonably read that as "any action I + declare this on is enforced somewhere", and it isn't. + + Server enforcement lives on the PLATFORM ACTION ROUTE — `POST +/api/v1/actions//` and the MCP/AI path — which is where `type: +'script' | 'flow' | 'modal'` actions execute. A `type: 'api'` action pointed at a + self-authored endpoint is fetched by the browser directly; that request never + reaches the platform, so nothing checks the declaration server-side and the + endpoint has to re-check the capability itself. The doc comment, the `describe()` + string (which is what surfaces in generated schema docs and editor tooltips), and + ADR-0066 D4 now say so. + + Behaviour is unchanged — this is the contract being honest about its edges. The + UI half's gaps were separate and are fixed in objectui. + +- 05154a1: Discovery stops telling Cloud/Enterprise deployments that nothing implements `ai` (#4093 follow-up). + + `CORE_SERVICE_PROVIDER` recorded `null` for `ai` because no **workspace** package provides it, and `serviceUnavailableMessage('ai')` therefore produced _"No implementation ships for the 'ai' slot"_. That is false: `@objectstack/service-ai` registers the slot in `objectstack-ai/cloud`. The table conflated "not in this repository" with "does not exist" — the same class of wrong answer the table was introduced to end, one step further out. + + Verified against the cloud repository rather than inferred: `packages/service-ai/src/plugin.ts` calls `ctx.registerService('ai', …)`, and the package is `private: true`, so there is genuinely nothing to install — which is why `null` stays right and an `Install X` sentence would still be wrong. `search`, `workflow` and `graphql` were checked the same way and nothing registers them in either repository, so their `null` and their "nothing ships" sentence are accurate. + + `ai` now carries a `REMEDY_DETAIL` sentence — _"Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework"_ — the mechanism `ui` already used. Both discovery (`services.ai.message`) and the `/ai` 501 body report it. + + This **removes** code: `/ai`'s domain had a local message override, added because the shared sentence was wrong there. Correcting the table fixed the domain _and_ discovery, which the override could never reach, so the override and `capabilityUnavailable`'s `message?` parameter are both gone. A slot whose sentence is wrong needs the table corrected, not a local exception. + + Also corrected: three places still describing `/ai`'s absent-service answer as a 404 (`docs/api/client-sdk.mdx`, `docs/releases/v17.mdx`, and a comment in `packages/client`'s URL-conformance test) — stale since that answer became 501. + + FROM → TO: `services.ai.message` and the `/ai/*` 501 body change text. Nothing branches on either — `status` and `enabled` are the contract, the message is prose for humans and agents. + +- 78caf51: fix(lint): the write-set diagnostics describe what the runtime actually does (#4271) + + `hook-body-write-unknown-field` and `action-body-write-unknown-field` told + authors the undeclared column "silently never lands in the stored record". + Measured on `main`, that is wrong in **both** directions. Nothing between the + body and the driver filters the key — `applyMutationsToInput` is a plain + `Object.assign`, and `validateRecord` walks declared fields on insert and + `continue`s past a key with no field def on update — so the driver decides: + + - **SQL** — the stray column enters the statement and the **whole write + fails** with a driver-level error (`table deal has no column named stagee`). + Nothing is stored, so the correctly-spelled fields of that row are lost too, + and the error names a column far from the body that wrote it. + - **Schemaless** (memory, MongoDB — both spread the payload without consulting + the declared field set) — the stray key **is** persisted, as an undeclared + column nothing downstream reads. + + A lint that misdescribes the failure it is warning about teaches the wrong + debugging instinct: an author told the value silently vanishes will not connect + the driver error they actually see to the typo that caused it, and on a + schemaless driver will not go looking for the stray key that is really there. + All three messages now state the split, matching the "What still happens at + runtime" description #4355 gave `content/docs/automation/hook-bodies.mdx`. + + Both outcomes are pinned by a new integration test — + `runtime/src/sandbox/undeclared-field-write-driver-split.integration.test.ts`. + Its insert cases run the full chain (real QuickJS sandbox, real hook body, real + engine, real driver against a real SQLite table), so "reaches the driver + unfiltered" is proved rather than asserted: if anything on that path ever + learns to filter, the SQL half stops throwing and the test goes red. The rule + headers, the `ScriptBodySchema` / `ActionSchema.body` notes and the two + still-unreleased #4271 changesets are corrected to match. #4355 fixed the + prose docs; this is the same correction on the surfaces that ship in the + packages — the diagnostic an author actually reads, and a test that pins it. + + `@objectstack/spec`: doc comments only — no schema or generated-artifact change. + +- c8124e5: fix(driver-sql): give `Field.datetime` one UTC storage form per dialect (#3912, #3942) + + Any window filter on a `Field.datetime` column returned an empty set on SQLite — + a dashboard `dateRange: last_30_days` on `created_date` read 0 while 29 matching + rows existed. + + There was never a storage _convention_, only a description of what better-sqlite3 + happened to do with a bound JS `Date`. Nothing enforced it — `formatInput` + deliberately left `datetime` untouched — so the form was decided by whichever + writer got there first: a JS `Date` landed as INTEGER epoch ms, while a REST/JSON + write (JSON has no `Date` type), a `defaultValue: 'NOW()'` slot, and the + platform's own `created_at` / `updated_at` all landed as ISO **TEXT**. One column + held both forms while the read path coerced comparands to epoch ms purely from + the _declared_ type. On SQLite's type ordering (`INTEGER < TEXT`) a two-sided + window collapsed to zero rows, and a one-sided `>=` matched every TEXT row + regardless of the bound. + + `Field.datetime` now has one canonical instant per dialect, produced by one + function applied on write **and** to every filter comparand, so the two sides of + a comparison cannot disagree about shape: + + - **SQLite** — `YYYY-MM-DDTHH:MM:SS.sssZ` text. Lexicographic order _is_ + chronological order, so range filters and `ORDER BY` read the column directly + and can use an index; `strftime` parses it, so the date-bucket expression needs + no CASE. + - **Postgres** — `timestamptz`, unchanged. The fix here is on the write and + comparand side: a zone-naive write was previously resolved against the + _server's_ timezone (measured 8 hours off on `Asia/Shanghai`), and an + un-anchored `YYYY-MM-DD` comparand meant the server's local midnight, so the + identical query over the identical instant landed a row on a different calendar + day than SQLite did. + - **MySQL** — `DATETIME(3)` instead of `TIMESTAMP`, a connection pinned to UTC on + both the mysql2 and the server layer, and a MySQL-spelled bind carrying the + same UTC wall clock. MySQL accepts neither the `T` separator nor the `Z` suffix + in a datetime literal, so datetime writes over REST had always failed outright; + `TIMESTAMP` additionally truncated milliseconds and could not store an instant + outside 1970..2038. + + Existing rows converge at schema sync. Both migrations are allowed to fail: they + log, mark nothing, and the read paths keep a repair expression, so an un-migrated + column still compares and buckets **correctly** — just unindexed. Neither can + repair instants the old timezone-ambiguous write path recorded wrongly; they + preserve what is on disk. + + Also closes #3928 (datetime `ORDER BY` mis-sorted on mixed storage) by + construction. Rationale is recorded as ADR-0053 addendum D-B1..D-B4. + + The analytics change is additive: a `coerceTemporalFilterColumn` companion to the + existing `coerceTemporalFilterValue` hook, so a raw-SQL strategy can normalise the + column side too. Absent hook → byte-identical SQL. + +- 4384921: fix(spec,drivers): `bypassTenantAudit` becomes a declared driver option, and `findOne` stops accepting a bare id (#4311) + + Three drivers built with `tsup` and tested with `vitest`, so no `tsc` had ever + read them. Onboarding them to the #4311 type-check ratchet surfaced 292 errors, + and most of what looked like sloppy test fixtures was the types being wrong. + + **`DriverOptions.bypassTenantAudit` is now declared.** It has been live for a + long time without being on the schema: `SqlDriver.auditMissingTenant` reads it + to suppress the "tenant-scoped write without `tenantId`" warning, the driver's + own warning text tells callers to set it, `ObjectQLEngine` sets it for + system-context calls, and `service-settings` / `service-datasource` pass it on + every global-scope write. Because the schema never had it, the driver read it + through `(options as any)` and no caller was type-checked. The declaration + states the limit as well: it silences a diagnostic and MUST NOT change which + rows a write touches — suppressing an audit warning is not a permission. + + The same cast covered `timezone`, `tenantId`, `tenantIds` and `preserveAudit`, + all long since declared. Those reads now go through `DriverOptions`, so the next + undeclared option fails the build instead of hiding behind an existing cast. + + **`SqlDriver.findOne(object, id)` is removed.** An undeclared + `typeof query === 'string' | 'number'` branch accepted a bare id. It was on no + contract, nothing outside that package's own tests used it, and the other two + drivers answered the identical call differently — `MemoryDriver` spreads the + string into `{0:'t',1:'1'}`, `MongoDBDriver` reads `query.where` as `undefined` + and returns an arbitrary row. It also bypassed the shared `findRows()` path, so + it skipped field selection, temporal coercion, unknown-column recovery and the + `singleRowLookup` ORDER BY decision. Spell an id lookup as the query it is: + + ```ts + -(await driver.findOne("task", "t1")); + +(await driver.findOne("task", { object: "task", where: { id: "t1" } })); + ``` + + **`SqlDriver.initObjects` declares the `tenancy` it consumes.** Each object is + fed to `computeAndRecordTenantField`, which reads `obj.tenancy` to pick the + tenant column and to set or clear the sticky explicit-opt-out — but the + parameter type listed only `{ name, fields }`, so a caller that spelled the key + correctly was rejected while the driver read it anyway. + `registerExternalObject` already had it. + + **`AnalyticsQueryInput` joins `AnalyticsQuery`.** `timezone` is + `.default('UTC')`, so the parsed type requires it and an authored literal does + not have it — the same two-tier split `QueryInput`/`QueryAST` already names on + the query side. `InMemoryDriver.create`/`bulkCreate` also declare their + `IDataDriver` return types; without them TS inferred the literal the method + builds and every other column of the created row disappeared from the caller's + view. + + One silent runtime bug fell out of the same pass: a driver test asked for + `orderBy: [['id', 'asc']]`, the driver reads `item.field`, a tuple has none, and + the sort never reached SQL. The tuple spelling appears nowhere else. + +- 7cb922e: chore(spec): the empty-state gate now scans platform-object definitions, where #3896 actually happened + + #3945 added a gate requiring any _"empty = permissive"_ statement in the spec to + be classified on purpose. It scanned `packages/spec/src/**/*.zod.ts` — and that + scope had a hole big enough to miss the bug it was built for. + + The sentence that shipped #3896, _"leave empty to share every record"_, was the + `description` of `sys_sharing_rule.criteria_json`, which lives in + **`plugin-sharing`**. The gate could not see its own crime scene. + + **Now scans `**/\*.object.ts`anywhere under`packages/`** — plugins, +`platform-objects`, `metadata-core`, and the `create-objectstack` templates + (a starter file is the highest-leverage thing a model copies from). 214 → 290 + files. + + **It immediately found a real one.** `sys_user_permission_set.organization_id` + declares _"NULL = applies in every org context"_: a user↔permission-set grant with + no org scope applies everywhere. That is deliberate and load-bearing rather than + an oversight — ADR-0095 D3 / ADR-0068 D2 derive the `platform_admin` posture from + an **unscoped** `admin_full_access` grant specifically, and an org-scoped grant of + the same set must not confer it. So the empty state is not merely wider, it is the + distinguishing input to the highest privilege in the system. Registered `open` + with that rationale and both enforcement sites cited, which is the point: the + answer now lives somewhere other than a maintainer's memory. + + Three fixes the new surface forced, each a case of the gate being wrong in a way + that mattered: + + - **Repudiated prose no longer fires.** #3929's own comment on `criteria_json` + reads _Deliberately NOT "leave empty to share everything"_ — the gate flagged the + sentence recording why the gate exists. Negation is now handled for the + imperative form as well as the token form (`deny-all`), and the escape is + deliberately narrow: the negator must be attached to the phrase, not merely + present in the line, because a false negative here is a missed over-share. + - **The owning property is found by nesting, not by a name list.** A field's prose + sits in a nested key, so the first attempt answered `description` for every + platform-object hit; skipping doc slots then answered `required`, the sibling + above it. What separates a field from its own config is indentation, so the + resolver now takes the nearest key at a shallower indent. + - **The property-search window is per-surface.** A platform-object `description:` + can sit 15+ lines below its field name; a `.zod.ts` statement sits beside its + property. Widening globally would let `.zod.ts` narrative be mis-attributed to a + distant property — turning a correct non-failing note into a wrong failure — so + `.object.ts` gets a wider window and the schema surface keeps its tight one. + + Also makes evidence resolution honest: entries are now parsed with the liveness + ledger's own `checkEvidence`, so prose around the paths works, several paths can + be cited, and another repo's path (`objectui: …`) is recorded without being + resolved here. The README already promised evidence resolved "like the ledger's"; + a single raw-path `existsSync` quietly did not. + + 6 new unit tests (32 total). No runtime behaviour changes. + +- 1d22114: chore(spec): classify what a restriction-shaped property's EMPTY value means (#3896 follow-up) + + #3929 fixed one field. `sys_sharing_rule.criteria_json` was optional, and its + absence evaluated to `find(object, { filter: {} })` — every record of the object, + granted to the recipient. The field description said so out loud: _"leave empty + to share every record."_ + + That sentence is the part worth generalising. For a platform whose premise is + that agents author metadata, **a field description is not documentation about the + contract, it is the contract the next author reads** — and omission is the + commonest authoring error a model makes, precisely because it produces no error. + When omission also lands on the widest grant, the likeliest mistake is the most + dangerous outcome, silently. + + Sweeping the spec surface found the same syntactic shape — an optional list or + predicate that "restricts" something — carrying opposite meanings when empty: + + | Property | Empty means | + | ------------------------------- | ----------------------------------------------- | + | `object.apiMethods` | `undefined` = unrestricted, **`[]` = deny-all** | + | `plugin-runtime.allowedSources` | _"empty = all allowed"_ | + | sharing `condition` | nothing is shared (#3929) | + + Nothing marked which was which. A maintainer knows by memory; a model cannot. + + **New gate — `pnpm --filter @objectstack/spec check:empty-state`**, wired into the + existing Spec Liveness Check workflow. It scans `packages/spec/src/**/*.zod.ts` + for statements declaring an empty state to be permissive and requires each to be + classified in `scripts/liveness/empty-state-registry.mts` as `scope` (selects a + range of work — empty = all is fine), `closed` (an access gate whose empty state + denies — the required posture for new gates), `open` (default-open on purpose, + mandatory rationale), or `output` (a computed projection, not authorable). + `closed` / `open` must cite where the posture is enforced, and the path is + resolved against the checkout so a pointer that rots is reported. 20 statements + across 214 schema files are now classified; adding an unclassified one fails CI. + + Detection matches the **statement**, not field names — names would be a guess, + and the liveness README is blunt about where a guessy check ends up ("a + permanently-noisy check is a check nobody reads"). It ignores negated tokens, so + the ⚠ `object.zod.ts` prints to warn that an empty whitelist is DENY-ALL is not + flagged as if it were permissive. Statements that resolve to no property are + narrative and reported as non-failing notes. + + **One behavioural correction.** `DynamicLoadingConfig.allowedSources` — a + supply-chain gate — documented `[]` as admitting every source. It now states the + `apiMethods` three-state: `undefined` = any source, `[]` = **deny-all**, a subset + = exactly those types. The empty ARRAY is closed; only ABSENCE is open. Collapsing + the two is what makes an allow-list _vacuous_, where the value an author reaches + by mistake is also the widest grant. + + The field has **no runtime consumer** — the whole `DynamicLoadingConfig` block + (`requireIntegrity`, `defaultSandbox`, `allowedSources`) is declared and + unenforced, the ADR-0049 false-compliance shape, and is not addressed here. That + is exactly why the wording mattered: an unimplemented property's description is + the specification whoever implements it will build to. It now carries an + `[EXPERIMENTAL — not enforced]` marker so authors are not misled meanwhile. + + Also registers the `sharing-rule-criteria-required` dogfood proof added by #3929, + which was declaring a `@proof:` tag the registry did not know about (unbound, for + the same reason as `showcase-bu-hierarchy-sharing`: sharing rules are authored at + stack level, so there is no governed per-type ledger entry to ratchet). + + No runtime behaviour changes. + +- b5f9397: fix(sharing,runtime): a `sort` passed straight to the engine never ordered anything; migrate every in-repo engine call to canonical QueryAST keys (#4346) + + Two changes with different weights, from one sweep of every in-repo engine + call site that still speaks a deprecated alias. + + **The bug — three dropped sorts.** #4346 made the engine fold `filter`→`where` + and `top`→`limit` on all six methods. The other four pairs in + `RPC_QUERY_ALIAS_SLOTS` (`select`, `sort`, `skip`, `populate`) are folded at + the RPC/wire layer only — their values need shape lowering that belongs to + those layers — and a **direct `engine.find()` never crosses that layer**. Three + call sites passed `sort` there, so it rode onto the AST untouched, every + driver's `Array.isArray(query.orderBy)` guard declined to emit an ORDER BY, and + the query returned an ordinary-looking, arbitrarily-ordered result: + + | call site | asked for | actually got | + | ----------------------------------- | ------------------------------------------------- | --------------------------- | + | `share-link-routes.ts` | shared AI conversation messages, `created_at asc` | messages in arbitrary order | + | `runtime/domains/share-links.ts` | same route, runtime-domain copy | same | + | `share-link-service.ts` `listLinks` | the 200 most recent share links | an arbitrary 200 | + + All three combine the dropped sort with a `limit` — the "latest N" shape whose + failure #4226 spelled out: an unapplied sort returns rows in arbitrary order, + which `limit` then slices into an arbitrary page. #4226 fixed that in the wire + normalizer; these calls sit one layer below it. `listLinks` had no test at all, + which is why it went unnoticed. Now pinned — on the option bag the engine + receives, not on row order, because the failure is that the key never becomes + `orderBy` and a fake engine honouring either spelling would pass either way. + + **The cleanup — 27 no-op renames.** Every remaining in-repo engine call passing + `filter` now passes `where` (approvals 5, auth 2, reports 6, sharing 11, + webhooks 2, plus the one `filters` in a spec doc example). These are strict + no-ops since #4346 folds the alias — the point is that the framework stops + depending on a spelling it asks users to migrate off, which is a prerequisite + for ever retiring the aliases. Service-level `filter` PARAMETERS (each + service's own public API, e.g. `listRequests(filter)`) are deliberately + untouched — those are not engine option bags. + + Two of the renamed calls were live victims of the #4346 bug rather than + cosmetic: `auth-manager`'s `stampIdentitySource` read the table's first row via + `findOne({filter})` and counted the whole table via `count({filter})`, so a + federated sign-in never stamped `source: 'idp_provisioned'`. #4346 already + corrected the behaviour; this makes the call say what it means. + +- 58a03d2: fix(objectql,spec,metadata-protocol,service-queue): engine option bags are now a closed contract — unknown keys throw instead of silently doing nothing (#4371 option 2) + + The engine declares `Engine*OptionsSchema` but never parses it at runtime, so + any option key outside the contract — a typo (`orderby`), a retired key + (`cursor`), a wire-protocol leftover (`object`, `count`), a key that only + works on other methods (`tenantId` on `count`) — rode along and was silently + ignored. All six methods now reject non-null unknown keys, naming the legal + set; retired keys (`cursor`/`distinct`) quote their #4286 tombstone; `null` + stays a withdrawal. + + Per-method legal keys = the method's schema keys plus the documented extras: + `searchFields` (now declared on `EngineQueryOptionsSchema` — it was read by + the engine's `$search` expansion and sent by the protocol layer all along), + `onFieldsDropped` on `update` (contract-declared write observability), and + the driver pass-through keys (`transaction`, `tenantId`, `tenantIds`, + `timezone`, `bypassTenantAudit`, `preserveAudit`) on `find`/`findOne`/ + `update`/`delete` — the methods whose bag actually reaches driver options. + `count`/`aggregate` never forward their bag, so pass-through keys there are + rejected rather than accepted-and-ignored. A drift pin holds the sets equal + to the schemas. + + Also closed in the same sweep: + + - A bag-level `object` key used to OVERRIDE the resolved object on the query + AST (`{ object, ...query }` spread order), splitting `ast.object` from the + table actually queried. The AST now keeps the resolved name; a direct call + passing `object` is rejected, and the protocol layer refuses a POST-body + `object` that contradicts the route (400 `QUERY_OBJECT_MISMATCH`) instead + of picking a winner. + - `findData` no longer leaks protocol-layer vocabulary (`object`, `count`, + `joins`, `windowFunctions`, `cursor`, `distinct`, non-aggregate `having`) + onto the engine bag. + - Nested expand ASTs (`expand: { rel: { sort } }`) reject the four wire-only + spellings exactly like the top-level bag (#4371 option 1 did the top level). + - The engine's OData-spelling reads (`$search`/`$searchFields`) are gone — + the protocol normalizes to the bare keys; a direct call passing them now + throws instead of half-working on one method. + - `DbQueueAdapter.purge`/`purgeFailed` passed `{ id }` — a key the engine + never read, so purge deleted NOTHING (each delete threw into a warn-level + catch) and purgeFailed always threw. Both now pass `{ where: { id } }`; + the test fake's `delete` no longer accepts the signature the real engine + rejects. + + Migration for direct engine callers (wire/HTTP callers are unaffected): pass + only the keys your method's `Engine*OptionsSchema` declares (plus the extras + above). Anything else previously did nothing — delete it, or move it to the + layer that owns it. + +- bcf1112: fix(service-datasource,rest)!: external-datasource refusals answer their own error code (#4249) + + #4225 / #4234 fixed the 503 `message` on the three routes in + `service-datasource/admin-routes.ts` that dispatch to `external-datasource` + rather than `datasource-admin`. The identical mis-attribution survived one field + over, on the 400 path — and machine-readably: one shared `badRequest` helper + hard-coded `DATASOURCE_ADMIN_ERROR`, which the ADR-0112 ledger defines as a + refusal _from the datasource-admin service_. So a `no such schema` raised by the + external-datasource introspector was reported as datasource-admin's, and where + #4225 misled a human reading prose, this misrouted a client switching on + `error.code`. + + `EXTERNAL_DATASOURCE_ERROR` is now registered in the error-code ledger — under + `@objectstack/service-datasource` and `@objectstack/rest`, the two packages that + emit it; per the ledger's own rule the per-package rows are provenance, not + identity — and `badRequest` takes the same `ServiceName` the route passed to + `resolve` (#4234), so the code, like the 503 message, comes from the service the + route actually dispatches to. + + Wire-visible changes: + + - **The three external-datasource routes' 400 `error.code`** — + `GET /datasources/:name/remote-tables`, `POST /datasources/:name/test`, + `POST /datasources/:name/object-draft` — is now `EXTERNAL_DATASOURCE_ERROR` + (was `DATASOURCE_ADMIN_ERROR`). Status, envelope, and `error.message` are + unchanged, as is everything on the six datasource-admin routes. No consumer + branches on the old code (grepped both repos, all the ADR-0112 sweep forms). + - **The rest surface's two introspection routes now have a failure contract at + all.** `GET /datasources/:name/external/tables` and + `POST /datasources/:name/external/tables/:remote/draft` carried no + `try`/`catch`, so the very same service operations that answer 400 through + the admin surface surfaced here as the adapter's non-envelope + `500 { error: 'No response from handler' }`. They now answer + `400 EXTERNAL_DATASOURCE_ERROR` in the declared envelope — one operation, one + failure contract, on both paths. (`EXTERNAL_IMPORT_ERROR` on the import route + is unchanged: a refused import is a different act from a failed + introspection, and its name says so.) + + Why a new registered code rather than reusing one: ADR-0112's ledger asks + _generic_ conditions to reuse the standard catalog — that argument carried + #4225's 503, where `SERVICE_UNAVAILABLE` is correct for all nine routes and only + the free-text `message` named the service. A refusal specific to one service is + exactly what registered extension codes are for, and the closed `ErrorCode` + union means correcting the attribution had to be a ledger edit. Widening + `EXTERNAL_IMPORT_ERROR` to cover introspection was rejected because these are + not imports; leaving the throws uncaught was rejected because the adapter's 500 + is not the declared envelope. + + The conformance rows that pinned the drift move with it, and each surface now + pins the refusal code per route the way #4234 pinned the 503 message per route. + + Pre-existing, like #4225: #3843 carried every code string over verbatim. + +- 9774b78: fix(driver-sql): `Field.time` gets a canonical storage form — `HH:MM:SS[.fff]` wall-clock text on every dialect (#3994) + + `Field.time` repeated the pre-#3912 `Field.datetime` pattern: writes were never + normalised and only reads were repaired, so one SQLite column accumulated bare + time-of-day TEXT, full-timestamp TEXT and INTEGER epoch ms side by side. + `find()` looked right; everything that compared the STORED form was wrong — + measured: a business-hours window filter silently dropped 4 of 7 rows, ORDER BY + sorted 14:30 before 08:00, a full-ISO write failed the statement outright on + both Postgres and MySQL, a bound `Date` stored a process-timezone wall clock on + pg, MySQL's bare `TIME` rounded `…00.500` up to `…01`, and a `NOW()` default + resolved against three different clocks on the three dialects. + + The #3912→#3942→#3954 construction, transplanted (ADR-0053 D-C1..D-C3): + + - One `canonicalTimeOfDay` — `HH:MM:SS`, `.fff` only when non-zero; `Date`/ + epoch/full-timestamp fold to the UTC time-of-day — applied on write + (`formatInput`), to filter comparands (`coerceFilterValue`, and thereby the + `temporalFilterValue` contract hook) and on read (`toTimeOnly`). + - SQLite: legacy columns converge at schema sync (`backfillCanonicalTimes`, + same `IS NOT`-guarded UPDATE, same log-and-swallow policy); until then the + filter paths wrap the column in the repair expression — correct, just + unindexed. `os migrate plan` lists the work as `normalize_time_storage` with + a row count. + - MySQL: new time columns are `TIME(3)`; legacy `TIME(0)` columns widen at + schema sync (`migrateMysqlTimeColumns`, plan kind `widen_time_columns`), + since zero-precision TIME _rounds_ fractional writes. + - `NOW()` defaults read the UTC clock on every dialect (Postgres previously + used the server zone, MySQL the inserting session's zone — and MySQL 8.0 + rejects a plain `CURRENT_TIMESTAMP` default on TIME entirely). + - `distinct()`/`aggregate()` present time columns exactly as `find()` does. + + `HH:MM:SS` writes round-trip byte-identically (the field-zoo `f_time` + contract); a minutes-only `HH:MM` now completes to `HH:MM:00`, and uninterpretable + values still pass through untouched. + +- cc60165: feat(lint): a flow `update_record` node writing an undeclared field gates the build (#4271) + + The write-set family #4305 (hooks) and #4344 (actions) opened had a third + surface, and it was the one the docs had spent the longest recommending as the + safe alternative to the other two. A flow `update_record` node whose + `config.fields` names a field the target object never declares was caught by + **nothing**: `validate-readonly-flow-writes.ts` walks that exact map and + explicitly stepped over the unknown key (`if (!meta) continue; // a +form/field-layout lint concern` — a referral to a rule that does not check + writes), and `validate-flow-template-paths.ts` checks the `{record.}` + READ tokens interpolated into node config, never the write-side key. So the + surface `hook-bodies.mdx` pointed authors at — "prefer a flow `update_record` + node, whose structural `fields` config is checked" — was the least checked of + the three. + + **New rule — `flow-node-write-unknown-field`, and it is an `error`.** Wired into + `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` report + it at once (one more place than the hand-wired readonly rule next door reaches). + + **Why it gates where its two siblings advise.** The hook and action rules are + advisory because they PARSE JavaScript: the finding is only as good as the + extractor, and a false positive kills an advisory lint. Nothing here is parsed — + `config.fields` is a literal map next to a literal `objectName`, the same + certainty `flow-update-readonly-field` already gates on one config key over. A + rule that errors on a write the engine _strips_ while only warning on a write + that names no column at all would be incoherent in the same `fields` map. + + And the runtime consequence is not the benign "consumer skips the unknown name + and renders the rest" that keeps `page-field-unknown` / `form-field-unknown` + advisory. Both halves were measured, not inferred: + + - Through the engine, an undeclared key reaches `driver.update` verbatim — the + flow executor calls the data engine directly, the UPDATE path strips only + readonly/readonlyWhen, and the SQL driver's `formatInput` / + `applyWriteColumnMap` pass an unrecognized key straight through (`m[k] ?? k`). + - On SQLite/knex it becomes `update "deal" set "name" = 'n2', "stagee" = 'won' … +→ no such column: stagee`. The statement is rejected **whole**: `name` — + spelled correctly, in the same payload — does not land either, and the step + fails with a driver error naming a column, far from the authoring mistake. + - On a schemaless datasource nothing rejects it, so the stray key is persisted + into a column the object never declares, where no schema-driven read returns + it. + + That is the call `validate-searchable-fields` makes for a stale entry and + `validate-flow-template-paths` makes for a filter-position token: gate when the + miss breaks or corrupts the operation, advise when it merely narrows the output. + + **One field index and one implicit-field set across all three surfaces.** + `indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule rather + than copied, so the three rules cannot drift on what is writable without being + authored — the shape #4330 collapsed one package over. + + Every skip exists so the gate only ever fires on a certainty, and each is + silent: a templated `objectName`, a non-literal `fields` map, an object this + stack does not define, an object that declares no fields at all (external / + datasource-introspected schemas, the same skip `validate-searchable-fields` + takes), and dotted keys (a nested-path write, not a top-level column). `runAs` + is deliberately NOT consulted, unlike the readonly rule that skips + `runAs:'system'` — an elevated identity bypasses the readonly strip, but no run + identity conjures a column. + + **Scope is declared as data, not left as silence.** `FLOW_WRITE_NODE_TYPES` + (today `update_record`) and `FLOW_WRITE_NODE_TYPES_DEFERRED` (`create_record`, + with its reason) are partition-tested against the CRUD node types that carry a + `fields` write map — derived behaviourally from the spec's executor-written + config schemas, not restated — so a node type that grows one later fails that + test until someone classifies it. + + `@objectstack/spec`: `ScriptBodySchema`'s "prefer a flow `update_record` node, + whose structural `fields` config is error-checked" note now names the rule that + makes it true. Doc comment only — no schema or generated-artifact change. + + Docs: #4355 had just rewritten `automation/hook-bodies.mdx` to record this gap + honestly — "**Prefer a flow `update_record` node when the write set is fixed — + but not for _this_ check** … writing a field the object never declares is + currently reported by nothing at all. On that one axis an L2 body is now the + better-checked surface." That bullet, and the matching note in + `automation/hooks.mdx`, are the two sentences this change makes false. Both now + say the axis has flipped back — and why the flow side lands a level _stronger_ + than the body side rather than merely level with it. + +- 91f4c78: fix(automation,objectql,spec): attribute `runAs:'system'` flow writes to the flow in the audit log (#4366) + + A `runAs:'system'` flow's data writes carried no attribution at all: the run + context resolved to `{ isSystem: true }` with no `userId` and no service + principal, so the audit writer recorded `user_id=null, actor=null` and the + record-history UI rendered every such row as "Unknown user" — business users + read the flow's own status mirror as data corruption. + + The `svc:*` attribution channel (ADR-0014 D2, `ExecutionContext.actor`) already + existed for exactly this class of writer; it was simply never wired end-to-end: + + - **service-automation** — `resolveRunContext` now stamps `flowName` alongside + `runAs`/`flowRunId`, and `resolveRunDataContext` labels a `runAs:'system'` + run's data context `actor: 'svc:flow:'` (fallback + `svc:flow:automation`). Attribution only — no security middleware keys on it. + - **objectql** — `buildSession` propagates `ExecutionContext.actor` onto the + hook session, closing the gap that left the audit writer's + `userId ?? session.actor` fallback unreachable from the engine path. + - **spec** — `AutomationContext.flowName` (engine-stamped, provenance) and the + hook session's optional `actor` field document the contract. + + No behavior change for user-attributed writes: `userId` still wins wherever it + is present. + +- c1d44f7: feat(lint): L2 hook-body writes to undeclared fields warn at author time (#4271) + + An L2 (`language:'js'`) hook body that writes a field the target object never + declares — `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: … })` + — runs clean in the QuickJS sandbox and reaches the driver **unfiltered**: + `applyMutationsToInput` is a plain `Object.assign`, and the write-path + validator walks declared fields on insert and skips a key it has no field def + for on update. What happens next depends on the driver, and neither half is + acceptable: + + - **SQL** — the stray column enters the statement and the **whole write fails** + with a driver-level error (`table deal has no column named stagee`). The + write is lost, and the error surfaces far from the mistake that caused it. + - **Schemaless** (memory, MongoDB) — the driver spreads the payload, so the + stray key **is** persisted: an undeclared column nothing downstream reads. + + No diagnostic anywhere, and nothing at the authoring site either way — the + #4001 "the mistake is invisible where it is made" family. The read side + (`hook.condition`) and the capability surface were already statically checked; + the write side was the one blind face, and `hook-body.zod.ts` carried it as an + **accepted gap**. + + **New rule — `hook-body-write-unknown-field` (advisory).** `@objectstack/lint` + now parses each L2 body (TypeScript parser; parsed, never executed, never + type-checked) and resolves its literal writes against the target object's + declared + system fields. An unknown field warns with a did-you-mean. Wired + into `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` + all report it; it never blocks a build. + + The recognized write shapes are declared as data — `HOOK_BODY_WRITE_PATTERNS`, + each entry carrying a canonical example that a reconciliation test round-trips + through the real extractor, so a pattern cannot be declared-but-unverified + (#3528's death). v1 ships three: + + - `ctx.input. = …` / `ctx.input[''] ⟨op⟩= …` → the hook's own + target object(s); flat-input envelope keys (`id`/`options`/`ast`/`data`) are + never treated as record fields. + - `Object.assign(ctx.input, { : … })` → same target. + - `ctx.api.object('').insert|create|update({…})` / `.updateById(id, {…})` + → the named object, at the **real** `ObjectRepository` payload positions + (`update(data)` — the payload is argument 0, not `update(id, data)`). + + Everything statically unknowable is skipped silently, favouring missed findings + over false ones: computed keys, spreads, non-literal payloads, dynamic object + names, wildcard-target (`object:'*'`) input writes, cross-package targets, + aliased input (`const doc = ctx.input`), and multi-target hooks where the field + exists on _some_ target (the body may branch per object — only an + everywhere-miss warns). + + The lint stays off the kernel boot path: the TypeScript compiler loads lazily, + only when a hook actually carries a JS body (same contract as the react-page + gates, guarded by `lazy-deps.test.ts`). + + `@objectstack/spec`: the `ScriptBodySchema` header's "write-set opacity — + accepted static-analysis gap" note now points at the lint instead, and spells + out what remains opaque so the warning's absence is not read as proof of + correctness. + +- f985b3f: fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + + **`packages/core/src/contracts/` was a dead near-copy of the real contracts, + and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had + ZERO importers — no relative import, no subpath export, not a tsup entry; + core's barrel has re-exported the `@objectstack/spec/contracts` versions all + along ("Re-export contracts from @objectstack/spec for backward + compatibility"). But the shadow had already **diverged** from the live + contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew + `rawBody?`; the copy never did), so anyone who grepped their way into it read a + stale contract that nothing enforces — the exact both-humans-and-AI failure + mode behind the false `http.server` exemption (#4382). Deleting it is + zero-risk by construction: nothing could reach it. + + **`http.server` is the canonical slot name, and the ledger now says so.** + `ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated + `'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin + register both two lines apart; cloud's two server entrypoints do the same). + Canonical is the only name present on EVERY provider path: runtime's + `config.server` path registers no alias, so the three cloud-connection plugins + that read the alias alone (marketplace-proxy, runtime-config, + marketplace-install-local) found an empty slot there — a live miss, now fixed: + all readers go canonical-first with the alias as a fallback that dies with the + alias registrations. The registrations themselves are untouched this release; + both sites now carry the deprecation note. + + **`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle + escape, declared once. Four consumers were each declaring it locally + (cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); + those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` + return is deliberate and documented at the single declaration: the handle's + real type belongs to the framework, and naming it would give the contract a + framework dependency. Adapters are not required to expose it; consumers + feature-detect. + + **`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options + their implementation has always accepted.** `bulkRegister`'s contract options + dropped the `MetadataWriteOptions` half its implementation intersects in + (`notify` is destructured on the method's first line); `bulkUnregister` + declared no options at all while the manager takes them. Same shape as the + `IDataEngine` read-methods gap from B2: a caller typed to the contract could + not reach the channel without erasing the lookup. Both additive; no implementor + or caller breaks. + + Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup + typed while touched). + +- 9a4932a: fix(cli,spec): i18n coverage actually gates view labels — the `defineView()` container is no longer skipped (#4123) + + `i18n/missing-view` had **zero producers**. `collectExpectedEntries` recognized + two view shapes and the compiled config is neither: + + 1. **Object-nested `listViews`** — objects do not carry `listViews` once + compiled (0 across every example). + 2. **Top-level named views** — guarded by `if (!view?.name) continue`. + + `defineView()` emits the aggregated View **container**, `{ list, listViews, +formViews }`, which per spec (`view.zod.ts`) has **no top-level `name`**: it is + keyed implicitly by its target object at `list.data.object`, exactly as + objectql's `resolveMetadataItemName` resolves it. So the guard rejected the + spec's own container shape, and with it every view in every example — 64 view + strings that the ratchet reported as fully covered. + + The walker now handles the container, emitting under the same + `objects.._views..*` convention the runtime resolver reads + (`viewLabel` in `@object-ui/i18n`) and the shipped platform bundles already + carry. An unnamed default `list` resolves under `_views.list`, matching the + console's `primary.name || 'list'`. `formViews` stays uncovered — form views + have no counterpart in that resolver convention, so keys for them would expect + translations nothing reads. + + `StrictObjectTranslation` gains the `_views` slot that + `ObjectTranslationDataSchema` already permits. Without it, `satisfies +StrictObjectTranslation<…>` rejects the very translations the gate now asks + for. + + The newly surfaced strings are **translated, not ratcheted** (the precedent set + when the object-less action landed): `check-i18n-coverage` stays at 665 with + none new. + +- 90c2b15: **The metadata-type registry told plugins to use a hook that does not exist (#4212).** + + `registerMetadataTypeSchema` and `registerMetadataTypeActions` both documented + themselves as things a plugin calls "from its `onInstall` hook", and named + `/api/v1/meta/types/:type` as the endpoint that would then serve the result. + Neither is real: + + - The kernel's plugin contract is **`init` / `start` / `destroy`** + (`packages/core/src/types.ts`). The `onInstall` / `onEnable` / `onDisable` / + `onUninstall` / `onUpgrade` family on `PluginLifecycleSchema` has no + invocation site anywhere in the runtime — `kernel.use()` validates and + stores, `bootstrap()` calls `init` then `start`. A plugin that followed the + documented advice registered nothing, and got no error saying so. + - **`/api/v1/meta/types/:type` is not a registered route.** What exists is + `GET /api/v1/meta` and the server-only `GET /meta/types`, both served from + `getMetaTypes()`. + + Both TSDoc blocks and `content/docs/plugins/adding-a-metadata-type.mdx` now + name the hook that runs and the endpoint that exists. The example is written as + a real `Plugin` with an `init(ctx)`, matching `DatasourceAdminServicePlugin` — + the one production caller of `registerMetadataTypeActions`, which has always + used `init` rather than the documented `onInstall`. + + Two facts worth knowing that the old text obscured, now stated: + + - `getMetaTypes()` reads these registries **at request time**, not from a boot + snapshot, so a type registered during `init` is served from the first call. + - Registering a schema does **not** by itself put a type in the listing. + `getMetaTypes()` enumerates types from the engine registry unioned with the + metadata service and then decorates each with its schema, so a type present + only in the schema registry is never reached. Declare the type via + `additionalTypes` as well. + + Documentation only — no behaviour change. The broader question of whether the + five declared-but-uninvoked lifecycle hooks should be implemented or retired + (ADR-0049 enforce-or-remove) is tracked in #4212; this change stops the + registry's own docs from sending authors at the dead one in the meantime. + +- 01e124d: Graduate `notify`'s nested `source: { object, id }` into the conversion layer (#4045). + + The `notify` executor tolerated a second spelling of its click-through target with + a bare consumer-side fallback: + + ```ts + const object = toStr(interpolate(cfg.sourceObject ?? src?.object, …)); + ``` + + Its own doc comment named `sourceObject`/`sourceId` **canonical** (they mirror the + `sys_notification.source_object`/`source_id` columns), so the nested form was an + alias tolerated by exactly the mechanism Prime Directive #12 calls debt — and the + one alias on this executor that #3796 missed when it moved `to`/`subject`/`body`/ + `url` into `flow-node-notify-config-aliases`. + + It now graduates the same way `filters` → `filter` and `object` → `objectName` + did: the conversion lifts it onto the canonical pair at load — including the + `AutomationEngine.registerFlow` rehydration seam — and the executor's fallback is + deleted, so no consumer-side dialect tolerance survives and the alias is declared, + tested and retirable on schedule (it rides the existing entry's window, retiring + at 18). + + Unlike the four renames this is a **1→2 destructuring**, which the pair mechanism + cannot express, so it is a small custom transform. It mirrors the `??` precedence + exactly: a canonical key already present wins and its nested counterpart is left + shadowed, matching how a shadowed alias is treated elsewhere. `source` is dropped + once at least one part is lifted; a `source` that is not an object, or carries + neither key, is left untouched rather than silently deleted. + + No behaviour change for authors — both spellings keep working, and a + half-specified target is still dropped rather than emitting a dead deep-link. + +- a13827e: fix(data): paging a sorted read is a partition of the result set, not five queries that share a WHERE clause (objectui#3106) + + `ORDER BY status LIMIT 50 OFFSET 50` names a sort key that does not identify a + row, and no backend promises that rows with equal keys keep the same relative + arrangement between two queries. MongoDB documents this outright — `sort` + + `skip`/`limit` on a non-unique key "may return the same document more than + once". So page 2 could repeat a row page 1 already showed and skip one nobody + ever saw: + + ``` + page 1: ORDER BY status LIMIT 5 OFFSET 0 -> [r05 r07 r11 r04 …] + page 2: ORDER BY status LIMIT 5 OFFSET 5 -> [r04 …] r04 again; one row never served + ``` + + Every page is full, every row is real and belongs, and the duplicate sits + several screens from the omission — which is why this is found by a user + counting records, never by reading a response. + + `SqlDriver` and `MongoDBDriver` now append a unique tie-breaker to any non-empty + `orderBy`, in the last requested key's direction (determinism holds either way, + but a same-direction suffix is the one an index can still walk in one pass). + `driver-memory` already conformed — `Array#sort` is stable over a table whose + order does not move — and now has a suite saying so, because that property is + implicit and easy to lose in a refactor that looks like a speed-up. + + `SqlDriver` adds it only for objects it created itself (`initObjects` records + those). A federated table (ADR-0015) may have no `id` column, and guessing there + would be worse than doing nothing: the unknown-column error is answered by + #3821's ladder retrying with **no ORDER BY at all**, trading a reshuffle among + ties for the loss of the caller's whole sort. + + The obligation is now normative on `IDataDriver.find`, with shared cases in + `@objectstack/spec/data` (`PAGINATION_CASES`) that all three drivers run — so a + future driver is held to it by a gate rather than by remembering. + + Not covered by this change: a paged read with **no** `orderBy`. Same defect, + wider blast radius, so it was carved out to #4363 rather than folded in — and + closed there, in the same release. The contract, the shared cases and both + drivers now cover a paged read whatever its `orderBy`, including none at all. + +- 59b85c0: `check:generated --reconcile-only` runs the package.json ↔ ledger reconciliation and + nothing else, and lint.yml's unfiltered required job now runs it on every PR. + + The aggregate already reconciled its GATED/NO_GENERATOR ledger against `package.json` + on every run, in both directions, so an unclassified `check:`/`gen:` script fails the + run instead of quietly dropping out of coverage. But the reconciliation only executed + where the aggregate executed — locally. CI runs the gates as separate steps, so a PR + adding a script without classifying it kept every CI gate green while + `pnpm --filter @objectstack/spec check:generated` — the wrapper AGENTS.md prescribes + before every spec push — exited at the reconcile stage on `main`, running zero gates. + + Not hypothetical: twice in three days. #4177 added `check:variant-docs` and `main` + stayed red for local wrappers until #4194 happened to collide with the same wall + (#4203); #4232 added `check:strictness-ledger`, caught while wiring this very step + and classified in the same change — it audits the hand-written strictness ledger + against the code it describes, so NO_GENERATOR. + + The step lives in lint.yml's "TypeScript Type Check" job rather than ci.yml's + `check-generated` job because the latter is gated on a `generated` paths filter that + does not watch `packages/spec/package.json` — the one file every offending PR must + touch; both offenders skipped that job entirely. The typecheck job is unfiltered and + required, so the meta-gate cannot go dormant — the same reasoning that already placed + `check:docs`, `check:skill-refs` and `check:react-blocks` there. + +- 6e357ed: test(spec): pin the input half of every recursive schema, so the third omission fails instead of shipping (#3786) + + A recursive Zod schema cannot infer its own type, so it carries a hand-written + `z.ZodType<...>` annotation. `z.ZodType` takes **two** type parameters, + ``, and `Input` defaults to `unknown`. Naming only the first + compiles, validates correctly at runtime, and silently un-types every authoring + path through the schema — `unknown` accepts everything. + + This package has made that mistake twice: + + 1. **#4171** replaced `z.ZodType` on the nav union with + `z.ZodType`, fixing the output half. `check-exported-any.ts` + was built to hold that fix and reads output only, so it reported green over + the half that was still broken. + 2. **#4221** found the consequence — `defineApp`, the documented authoring entry + point, compiled `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean — + and **#4227** then named both parameters on the six remaining recursive + schemas. + + Both fixes are correct and both are currently unpinned. #4227 considered a + `.d.ts`-level scanner and declined it for a good reason: separating a deliberate + single-parameter `z.ZodType` (the generic in `contracts/llm-adapter.ts` takes + a caller-supplied schema, where the input side is nobody's business) from an + omission needs heuristics on emitted type names, and `check-exported-any.ts`'s + own rule is zero false positives so red keeps meaning broken. Its commit + nominated #4221's assertion-file pattern instead. This is that pattern, applied + to the eight schemas #4221 did not cover: `QuerySchema`, `JoinNodeSchema`, + `FieldNodeSchema`, `FilterConditionSchema`, `NormalizedFilterSchema`, + `StateNodeSchema`, `ValidationRuleSchema`, `FormFieldSchema`. + + `src/recursive-schema-input-assertions.ts` gives each one a positive probe (the + authoring shape still compiles — guarding an input type drawn too tight) and a + negative probe reached **through `z.input`**, the way a consumer + gets there. The negative is load-bearing: it is a value `unknown` would accept + and the real type rejects, so dropping a type parameter turns the suppression + unused and `tsc --noEmit` fails on that line, by name. + + Verified by mutation in both shapes a regression can take: + + - `QuerySchema` back to one parameter → the pin fires **and** `JoinNodeSchema` + cascades a type error, because `JoinNodeInput.subquery` is `QueryInput`. + - `StateNodeSchema` back to one parameter → **only** the pin fires. Nothing else + in the package references its input, so without this file that regression is + completely silent. That is the case the file exists for. + + No runtime change, no new public export (`check:api-surface` reports no diff); + the module is referenced by no tsup entry and re-exported by no barrel. + + Also classifies `check:strictness-ledger` in `check-generated.ts`'s ledger. It + landed in #4232 without an entry, so `check:generated` was failing on `main` + itself — the same cross-PR race the `check:variant-docs` entry above it already + documents, now on its second occurrence. + +- 31e0be9: Flow metadata is canonicalized inside structured regions, not just at the top level (#4347). + + `registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion + table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them + walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in + its open `config` (`loop.config.body`, `parallel.config.branches[]`, + `try_catch.config.try`/`.catch`), so all three stopped at the container and metadata came + out **position-dependent**: the same node converted at the top level and did not one level + in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a + top-level edge and left a bare string on a loop-body edge. + + The reporting app shipped three sweeps whose gates never opened. Each run reported + `success: true`, queried correctly, selected exactly the right records, and then did + nothing — which is indistinguishable from "this sweep had no work to do" unless you assert + on records written. + + - **`mapFlowNodes` recurses into regions**, to any depth. Every conversion in the table now + reaches a nested node, which matters most for the two that change behaviour rather than + spelling: a `webhook` / `http_request` callout inside a loop body kept a type no executor + owns (the run failed), and a `delete_record` kept `config.filters`, leaving the canonical + `filter` the executor reads absent — the erased-condition hazard + `flow-node-crud-filter-alias` exists to prevent. Notice paths carry the region + (`flows[0].nodes[3].config.body.nodes[1].config.filter`), so the warning points at the + node to edit. + - **New `normalizeControlFlowRegions`**, called at the load seam after + `validateControlFlow`: each region is parsed through its own schema (recursively — regions + nest), so nested edges and nodes carry the same canonical shapes as top-level ones. A + region that does not parse is left untouched; rejecting one stays `validateControlFlow`'s + job, so which flows register is unchanged. + - **New `collectFlowGraphs`** yields a flow's own graph plus every nested region, each with + a scope label. Both predicate validators iterate it instead of `flow.nodes` — the engine's + `validateFlowExpressions` and `@objectstack/lint`'s author-time + `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is now + caught inside a loop body too, naming the region (`loop 'sweep' body · edge 'b1' …`). It + used to pass `objectstack validate`, pass registration, and fail at run time with the + diagnostic suppressed. + + The container executors already parse their own config at run time (`parseNodeConfig`, + #4277), so a nested predicate did evaluate correctly on current `main` — what was still + wrong is everything that reads a region _without_ re-parsing it (the Studio designer, + `getFlow`, the version history), and every conversion, none of which the executors replay. + + Also hardened, per the issue's secondary finding: `evaluateCondition`'s legacy `{var}` + template path **refuses an unresolved dotted reference** instead of comparing it as a + string. `'oppRecord.amount > 500000'` was compared `'oppRecord.amount' > '500000'` — `'o'` + against `'5'` — so it was constantly true regardless of the amount: silently wrong in the + _true_ direction, a gate that reports success while never gating. It now throws with the + source and the fix (a CEL envelope, or brace the reference if the `{var}` dialect was + meant), the same "never swallow a broken predicate" rule ADR-0032 §1c set for the CEL path. + The `try { … } catch { return false }` around that block went with it: nothing in it throws, + so it guarded nothing and would have swallowed the new refusal straight back into the silent + wrong answer. Bare-word comparisons (`'{status} == active'`) and `{var}` templates are + unchanged — only dotted references, which substitution can never leave behind, are refused. + +- 4bfd455: One declaration of where ADR-0031 regions live (#4401). + + A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the + type system says which key on which node type holds one, so every pass that needs to reach + a region node has to be told — and within one week three of them were told separately, by + two changes that were each correct on their own: + + | pass | package | table it carried | + | --------------------------------------------------------------------------- | ------- | ------------------- | + | `mapFlowNodes` (ADR-0087 conversions) | `spec` | `FLOW_REGION_SLOTS` | + | `validateControlFlow` / `normalizeControlFlowRegions` / `collectFlowGraphs` | `spec` | `regionSlotsOf` | + | `walkFlowNodes` (lint flow rules) | `lint` | `REGION_SLOTS` | + + Each pinned its own copy with its own reconciliation test. So every copy was protected from + drifting away from the schemas, and **nothing would have failed if the copies drifted from + each other** — while adding a fourth construct meant editing three places, and missing one + reproduces exactly the silent blind spot #4347 and #4380 were both filed about. + + - New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (plus the + `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is now the only statement + of the fact. It lives in an **import-free** module so `spec/conversions/walk.ts` can read + it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema + its value parses as stays in `control-flow.zod.ts`, which is schema business. + - The three reconciliation tests collapse into one, `region-slots.test.ts`, keeping the + strongest of them: it derives each construct's region keys **behaviourally**, by asking + the config schema what it actually accepts in a region shape, rather than reading names + off `.shape`. It also probes every other exported `*ConfigSchema`, so a new + region-bearing construct cannot be added without either declaring its slots or failing + here. + + The three **walks** are deliberately left separate. They take different inputs (parsed + `FlowNodeParsed` vs raw authored records), yield different units (a graph, a node, a + copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node + labels — consumer logic, not protocol (Prime Directive #2). Merging them would trade a + duplicated four-line table for a walker that serves nobody well. Only the fact they all + need is shared. + + No behaviour change: every existing test passes unchanged, which is the point of the + exercise. + +- ffd2ce2: `registerFlow`'s remaining validators cover structured regions (#4389). + + #4347 closed the conversion and predicate halves of "metadata behaves differently + depending on how deep it sits". Three validators were left walking `flow.nodes` only, so + the same class stayed open one layer over: an ADR-0031 container keeps a whole sub-graph + in its open `config`, and each of these checked _part_ of the flow while reporting on all + of it. + + - **`validateControlFlow` recurses.** A container nested inside another container's region + was never validated at registration — it reached run time, where `runRegion` → + `findRegionEntry` throws mid-iteration, after the enclosing loop has begun and its side + effects have landed. This cannot break a working flow: everything newly rejected was + already guaranteed to throw on execution. It also closes cycle detection over nested + regions, since region bodies are cycle-checked by `analyzeRegion` here rather than by + `detectCycles`. + - **`validateNodeTypes` covers region nodes.** Soft-fail. A node in a `loop` body is as + executable as one beside it, so the warning that exists to predict `NO_EXECUTOR` went + quiet on exactly the nodes whose run-time failure is hardest to place. + - **`validateNodeConfigKeys` covers region nodes.** Hard-fail. `visibleIf` is the typo + #4277 exists to catch, and moving the node into a region restored the silence #4277 + closed. Violations carry the region (`loop 'sweep' body · node 'w' …`). No + double-reporting from the container side: all three container descriptors declare their + region slot as a bare `nodes: { type: 'array' }` with no `items`, so the schema-lockstep + walk stops there instead of descending twice. + + **Measured before extending the two hard-fail checks**, since widening a rejecting + validator is a behaviour change rather than a bugfix: registering every flow in + `app-showcase`, `app-crm` and `app-todo` through the real `registerFlow` and re-running + each validator's own code over all 9 region graphs produced **0 new findings**. Nothing + that registers today stops registering, so the checks land at their existing severity + rather than staged through a warning window. + + `validateNodeInputSchemas` is deliberately **not** extended. It declares 0 uses across all + 159 example flow nodes, and its check compares a config value's runtime type against the + declared one — so extending it would newly fail a region node carrying a `{var}` template + string in a `number`-typed slot, which is a live authoring shape. Widening a check with a + known false-positive mode and no demonstrated reader is not worth it; the traversal gap is + noted on #4389 instead. + +- a831df1: chore(liveness): `report.order` is live — objectui now lowers it onto the selection (#3916) + + `ReportSchema.order` shipped as `planned` + `authorWarn`: the framework half was + complete (schema, `reportSelectionOrder`, executor), but objectui's + `DatasetReportRenderer` built the selection it posted and never carried the + declaration into it, so an authored ordering reached no query. Marking it `live` + then would have been the exact failure the gate exists to catch. + + objectui#2964 landed that wiring — `useDatasetRows`, the single fetch choke point + behind every report path, now carries the lowered ordering across all four call + sites (grouped table, embedded chart, matrix cross-tab, each joined block), with + the ordering in the refetch signature and scoped per sub-selection so the + chart's narrower x/y query cannot post a key it never selected. + + So the ledger entry flips to `live`, gains the framework evidence paths, and + drops `authorWarn` / `authorHint` — an authored `order` now does what it says, + and the advisory that it did not is no longer true. + + No behaviour change in this repo; the ledger is the deliverable. + +- f752ee3: feat(analytics): order the time axis by default, and give reports a sort declaration (#3916) + + A matrix report with a date dimension across rendered its columns in arbitrary + order — `2026-07-01, 2026-07-05, …, 2026-07-02`. Declaring `dateGranularity` on + the dataset dimension made the bucket keys _sortable_ (`2026-07`, `2026-Q3`) + without making anything _sort_ them, and the report author had no way to ask: + `DatasetSelection.order` existed on the wire, but `ReportSchema` had no ordering + field at all (dashboard widgets had their own `options.sortBy` channel; reports + did not). Nothing in the chain supplied an order either — `resolveOrdering` + returned `undefined` unless the selection carried one explicitly, the ObjectQL + aggregate path has no ordering grammar so its buckets came back in Map-insertion + order, and the pivot builds its column headers in row-arrival order. + + - **A selected time dimension is now chronological by default.** When a + selection states no `order` (and no `limit`, whose own fallback already + ordered by every dimension), each selected dimension the cube types as `time` + defaults to ASCENDING, in selection order. Bucket keys are minted sort-stable + precisely so this works — `2026-07` sorts after `2026-06`, `2026-Q3` after + `2026-Q1`. This lands on both strategy paths: a real `ORDER BY` where native + SQL serves the query, and the executor's post-pass where a date-bucketed query + is handed to the ObjectQL path. Null / empty buckets stay last, as everywhere + else. Deliberately narrow: only time dimensions get a default, so grids with + nothing wrong with them are not reordered. + - **Reports can declare an ordering.** `ReportSchema.order` (and + `blocks[].order` for a `joined` report) is a list of `{ by, direction }` sort + keys, most significant first — an array, not a `Record`, because key order is + the contract and JSON object key order should not have to be. `by` must name a + dimension the report groups by (`rows` / `columns`) or a measure it displays + (`values`); anything else fails at authoring time rather than becoming an + ordering that silently does nothing. Duplicate keys are rejected. A `joined` + report orders per block — declaring `order` on the container is an error. + `reportSelectionOrder()` lowers the list into the `DatasetSelection.order` a + renderer posts, and returns `undefined` for an empty list so the runtime's own + defaults still apply. + + An explicit `order` still wins outright — the chronological default is a + default, not a policy, so "newest month first" is one declaration away. + + `report.order` ships as `planned` + `authorWarn` in the liveness ledger: the + framework half is complete and live (schema, lowering helper, executor), but + objectui's `DatasetReportRenderer` does not yet carry `report.order` into the + selection it posts. The default time-axis ordering needs no renderer change and + is live now. + +- a1b61e0: Request bodies are now checked against the schemas the API catalog declares for them (#3899, the request-side dual of #3877). + + **Routes that now answer `400 VALIDATION_FAILED` + `fields[]` for a body violating their declared `requestSchema`** (previously the body was consumed raw, and a malformed one silently executed different semantics): + + - `POST /data/:object/query` — body must be a QueryAST (`FindDataRequestSchema`); a garbage body used to degrade into an unfiltered full read. The path `object` is now pinned into the forwarded query (a body `object` can no longer contradict the path). + - `POST /data/:object` / `PATCH /data/:object/:id` — body must be a record object (`CreateDataRequestSchema` / `UpdateDataRequestSchema`). + - `POST /data/:object/batch` — body must be a `BatchUpdateRequestSchema` (`operation` + `records[]`). + - `POST /data/:object/createMany` — body must be a bare JSON array of records (`CreateManyDataRequestSchema`); `{ records: [...] }` (updateMany's envelope) is rejected with a pointer. + - `POST /notifications/read` — body must be `{ ids: string[] }` (`MarkNotificationsReadRequestSchema`); a misnamed key used to become `markRead(userId, [])` — a 200 no-op that never cleared the badge. + + **Dispatcher automation routes now validate their bodies** (no catalog schema; hand-written guards): + + - `POST /automation` and `PUT /automation/:name` require a flow-definition object, and POST requires a non-empty `name` — a mistyped `name` used to register the flow under the key `undefined` and echo 200. + - `POST /automation/:name/toggle` is strictly `{ enabled?: boolean }` — `{"enable": false}` (one letter off) used to ENABLE the flow and answer 200 `{enabled: true}`; it is now a 400 naming the offending key. An empty body still means enable. + + **`QuerySchema` now declares the search contract ADR-0061 actually serves** (additive): `search` accepts the canonical bare query string as well as the structured `FullTextSearch` form, and the server-validated `searchFields` narrowing is formally declared. Previously the schema declared only the object form while every surface (and the ADR's own conformance proof) sent the string — drift that surfaced the moment request bodies started being validated. + + **Catalog corrections in `@objectstack/spec` (`plugin-rest-api.zod.ts`)** — documentation-only tables: + + - `DEFAULT_NOTIFICATION_ROUTES` drops the four device/preferences endpoints — those server routes were removed in #3612 (never built), yet the table kept declaring them, `requestSchema` and all. + - `DEFAULT_AUTOMATION_ROUTES`' trigger endpoint path is corrected `/trigger` → `/trigger/:name` (the mounted path; the flow name rides the path) and its `AutomationTriggerRequestSchema` declaration is removed — that schema never described this route's wire shape. + - `DEFAULT_DATA_CRUD_ROUTES` gains the `POST /:object/query` entry (mounted since forever, previously undeclared), repoints create/update to the schemas the routes actually validate (`CreateDataRequestSchema` / `UpdateDataRequestSchema` — the old `CreateRequestSchema`/`UpdateRequestSchema` names described a `{ data }` envelope the wire never had), and drops `requestSchema` from GET/DELETE entries (path/query-bound inputs; nothing can violate them as a body). + - New gates: catalog `requestSchema`/`responseSchema` strings must resolve to real exported Zod schemas, `requestSchema` may only sit on body-carrying methods, and every declared `requestSchema` on a mounted route has a violating-body → 400 conformance case (`packages/rest` + `packages/runtime` request-schema-gate suites). + + Migration: clients that already send the documented shapes are unaffected. If you relied on a malformed body being silently accepted (e.g. posting `{ records: [...] }` to `createMany`, a non-boolean `enabled` to toggle, or an off-schema analytics/query body), fix the request to the declared shape — the 400's `fields[]` names each offending key. + +- af2a095: fix(data): `searchFields` / `groupBy` / `aggregations` naming a field that does not exist are rejected, not silently degraded (#4254) + + #4226 closed `sort` / `select` / `expand`; with the filter axis (#4134 / #4164 / + #4181 / #4121) that made four field-naming read axes that either apply or fail. + The same machine kept leaking on the remaining three, and each failure corrupted + something the closed axes never touched: + + ``` + search=alpha&searchFields=no_such -> 200 MORE rows than the narrowing allowed + groupBy=[no_such] -> 200 [{no_such: null, n: }] N groups collapsed into 1 + sum(no_such) -> 200 0 — indistinguishable from a real zero + ``` + + Each is now refused at the shared normalizer, so `GET /data/:object`, + `POST /data/:object/query`, the export route and the runtime dispatcher give + one answer instead of four. + + - **`searchFields` → `400 INVALID_FIELD`.** The `select` failure with the sign + flipped outward: the engine dropped unknown names and, when that emptied the + override, fell back to the FULL searchable set — so a parameter that exists + only to narrow a search widened it, and it changed which ROWS came back, not + just which columns. Its only in-framework caller is `GET /data/:object/export` + — the route whose `search` support just shipped so exports would stop + downloading "the unsearched superset … in a file that looks authoritative"; + a typo'd `searchFields` did exactly that, one parameter over. Three causes, + three messages, because the fixes differ (the split #4226 drew on expand): a + name that is no field is a request typo; a REAL field outside the searchable + set needs the object changed (its message names the declared + `searchableFields` or the auto-default's type rule, whichever applies); and + a `searchableFields` entry that names no field is a STALE DECLARATION — a + bug on the object, called out as such because clients (objectui's list + search) echo the declaration verbatim. The allowed set is resolved by the + same `@objectstack/spec/data` function the engine's search expansion + consumes (`resolveSearchFieldResolution`, moved from objectql), so the gate + cannot drift from what search actually scans. + - **`groupBy` → `400 INVALID_FIELD`.** The in-memory aggregation path projects + an unknown column as `null` for every row, so all rows landed in ONE bucket + whose count is the true row count — structurally perfect, identical to "this + column really holds a single value". A chart draws one bar; nothing says the + grouping never ran. Native SQL aggregation errors on the same input, so which + backend a deployment sits on decided the answer — the "two routes, opposite + answers" split, one axis over. + - **`aggregations` → `400 INVALID_FIELD`.** `sum()` folded a column of + `undefined` to `0` — the exact number an empty quarter produces, in reports + whose whole job is to be believed (`avg`/`min`/`max` answered `null` the same + way). `count` with no `field` (or the `'*'` sentinel) is the one legitimate + field-less form and passes. + - **Unreadable SHAPES on the aggregation axes → `400 INVALID_QUERY`** — the + standard-catalog code that had no emitter since it was written, like + `INVALID_SORT` before #4226. A string `groupBy`, an entry naming no field, a + function or `dateGranularity` outside the spec enums, a missing `alias`: each + slipped past the `Array.isArray` routing guard (rows returned UNGROUPED) or + computed a silent placeholder (`null` results, a column keyed `"undefined"`, + one bucket per raw value under an unknown granularity). + + Tiering is unchanged from #4226: registry + field map present → authoritative; + no registry / no field map / legacy array field map → the NAME gates skip (shape + gates still apply — they need no schema). The engine's own tolerance is + untouched: internal callers reaching `engine.find()` / `engine.aggregate()` + directly are unaffected. `@objectstack/rest` also stops logging + `INVALID_FILTER` / `INVALID_SORT` / `INVALID_QUERY` rejections as + "[REST] Unhandled error" — they are client mistakes the response already + explains, as `INVALID_FIELD` always was. + + Requests that name real fields are unaffected. + +- c65e529: Retire ci.yml's `generated` paths filter and its `Check Generated Artifacts` job; every + spec artifact gate now runs in lint.yml's unfiltered, required TypeScript Type Check job. + + The filter was a hand-maintained duplicate of each gate's input set, and nothing + reconciled the two. It drifted three times on record, each found by accident and written + up in a comment rather than gated: + + - #2584 moved a generated page and the filter kept watching the old path, so hand-edits + to the generated block went unchecked for months. + - #3855 listed specific spec paths but no schema dirs, so `check:authorable-surface` went + dormant on exactly the PRs that remove an authorable key. + - `packages/spec/json-schema.manifest.json` — the #2978 ratchet, and the only durable + record of every schema ever emitted since `json-schema/` is gitignored — was never + watched at all, so a PR retiring a key from it skipped its own verifier. + + Six of the ten gates had already escaped to the typecheck job one at a time, each with a + comment explaining that the filter had failed them. This moves the last four + (`check:skill-docs`, `check:spec-changes`, `check:upgrade-guide`, + `check:authorable-surface`) and deletes the filter, so there is no second ledger to keep + in sync — the failure mode #4255 fixed for the `check:generated` ledger, removed at the + source here rather than gated. + + Affordable because the work was already being done: `check:docs` in that job runs + `gen:schema` — the same `scripts/build-schemas.ts` that backs `check:authorable-surface` — + and that entire step measures 4s in CI, against a 5-minute job dominated by the workspace + build. All four read source via tsx and need no build. + +- 94a0bbc: fix(security)!: a disabled RLS policy no longer grants — found by re-verifying the ledger's security subset (#3896 follow-up) + + **The fix.** `RowLevelSecurityPolicySchema.enabled` promises, verbatim: _"Disabled + policies are not evaluated."_ Nothing read it — not the collection site, not the + projection round-trip, not the compiler. Because applicable policies OR-combine + (any match allows access), a policy an admin switched off **kept contributing its + grant**: disabling a too-permissive policy silently changed nothing. That is the + #3896 shape — a documented security control whose real behaviour is wider than + its contract — one layer up, on RLS instead of sharing rules. + + `getApplicablePolicies` now excludes `enabled === false` before any matching, at + the single choke point both the find path and the analytics path flow through — + the same place, and the same ADR-0049 enforce-or-remove resolution, as the + formerly-unenforced `positions` domain. Exact `=== false` on purpose: the schema + defaults `enabled` to true and projection rows may omit the key, so absent stays + active. Four tests pin both directions. Access-narrowing only: no policy grants + MORE after this change, and nothing in-repo authors `enabled: false`. + + **The audit that found it.** All 44 entries of the liveness ledger's security + subset (`permission` 33, `position` 4, `object` sharing/access 7) were + call-graph-closed by hand and stamped `verifiedAt: 2026-07-30` — the subset's + first-ever re-verification (previously 4 dated entries repo-wide, and the last + sweep that cited preview renderers went 10-for-13 wrong). Beyond `enabled`: + + - `rowLevelSecurity.priority` → **dead + authorWarn**. Not merely unimplemented: + policies OR-combine (the schema's own describe says most-permissive-wins), so + the promised "conflict resolution" semantics cannot exist. A REMOVE candidate + per the #3715/#3950 precedent while the v17 breaking window is open. + - `rowLevelSecurity.label` / `description` / `tags` → dead (benign display — + no consumer in either repo; deliberately not authorWarn'd). + - `tabPermissions` was UNDERSTATED: the note said only `'hidden'` is read, but + hono's rank merge reads all four visibility values across resolved sets, and + the `me-apps-and-everyone-baseline` dogfood test exercises it. Evidence + upgraded; noted as a proof-binding candidate. + - `allowExport` re-verified TRUE against the suspicion that it was + projection-only: the export route carries its own caller-level 403 gate + (`enforceExportPermission`), fail-closed when the security service cannot + answer, separate from the object-level 405. + - `allowTransfer/Restore/Purge` notes re-confirmed accurate (M2 operations still + unshipped; the RBAC gates are pre-mapped fail-closed). + - `object.ownership` evidence had rotted (line drift) — refreshed; six other + object-level security entries re-cited and stamped. + + No other runtime behaviour changes. + +- 627b188: fix(seed-loader): count reference fields dropped from rows that were still written + + The loader had two failure outcomes and only counted one. A record it cannot + write is counted in `errored`. But an unusable **reference value** (an object + where a natural key belongs, an array on a single-value field) is removed from + the record — never written as NULL, which would sever an existing link on + upsert replay — and the row is written **without it**. Nothing counted that. + + So a load that quietly severed N associations reported `totalErrored: 0`, and + every count-driven surface read clean. The CLI boot banner — the one seed signal + that survives `os dev`'s boot-quiet window and the default `warn` level — printed + `showcase 42 rows`, and the warn line said `0 dropped record(s)`: true, and + useless ([#3932](https://github.com/objectstack-ai/objectstack/issues/3932)). + + `SeedLoadResult.referencesDropped` and `SeedLoaderSummary.totalReferencesDropped` + now count it. It is deliberately **not** folded into `errored` — the row _was_ + written, so that would break the `inserted + updated + skipped` reconciliation + against `total`. The banner names it separately: + + ``` + ⚠ Seeds: showcase 42 ok / 3 lost links ⚠ + ``` + + Both counters are additive with a `0` default, so an existing producer or + consumer of `SeedLoaderResult` is unaffected. + +- 8d4eae7: fix(seed-loader): resolve natural-key ARRAYS for multi-value lookups + + A `multiple: true` lookup / `user` field stores an array of ids, so its seed + value is an array of natural keys (`authors: ['Alice', 'Bob']`). Reference + resolution only ever accepted a single string: the array tripped the + "expected a natural-key string but got an object. Pass the target's `name` + value as a plain string" guard — impossible advice for a field that holds + several references — and was then DROPPED from the record. The row landed with + the whole association missing and only a warn in the log + ([#3911](https://github.com/objectstack-ai/objectstack/issues/3911)). + + Every element now resolves independently (in-load records first, then the + database, then pass 2), and the field lands as an array of target ids. A lone + string is accepted as one-element shorthand for the array shape the field + stores. Deferral is all-or-nothing per field — a partially-resolved array is a + corrupt association, so pass 2 re-resolves the whole authored array — and a key + that never materializes is a reported load error naming that element, not a + silent drop. + + An array passed to a genuinely **single-value** reference field is still + rejected, now with advice an author can act on: declare the field + `multiple: true`, or pass one natural key. + + `ReferenceResolution` (`@objectstack/spec/data`) gains an optional `multiple` + flag carrying the field's array-ness into resolution; it is additive and + defaulted-absent, so existing dependency graphs are unaffected. + + **Authoring types.** `defineSeed`'s per-field value type now widens a + `multiple: true` lookup to `string | string[] | null` (a lone string stays legal + — the loader accepts it as one-element shorthand). `master_detail` is inherently + single and is not widened, and an array on a single-value lookup is still a + compile error. To make that reachable, `Field.lookup` became generic over its + config (``) so `multiple: true` survives as a + literal instead of widening to `boolean`; the return type is intersected with + `FieldInput` so its optional surface is unchanged. Type-level only — the + returned object is byte-identical at runtime. + +- ccd9397: fix(security)!: a sharing rule with no criteria now shares NOTHING instead of every record (#3896) + + `SharingRuleSchema` has always required `condition`, and its doc is explicit + that a predicate the compiler cannot lower is _"skipped and logged — never + seeded as a permissive match-all (ADR-0049)"_. The declared/seed path honoured + that. The two other ways to create a rule did not: + + - **`POST {basePath}/sharing/rules`** plucks its body field-by-field into + `SharingRuleService.defineRule`, which validated `name` / `label` / `object` / + `recipientType` / `recipientId` — and not `criteria`. A missing, `null`, or + **misspelled** key (`criterias`) was stored as `criteria_json: null`, answered + `201` with no warning, and evaluated as + `find(object, { filter: {}, context: SYSTEM_CTX })`: every record of the + object, up to 5000, granted to the recipient. Triggering it took a typo, not + an attacker. + - **Authoring a rule in Setup** is a direct `sys_sharing_rule` insert, which + never reaches `defineRule` at all. + + Empty criteria is now rejected everywhere a rule can be written, and — because + rules created before this gate are already in the table — the evaluator refuses + to act on one regardless of how it got there. + + - **`defineRule` rejects a match-all criteria** with + `VALIDATION_FAILED: criteria is required …`, alongside its other required + fields. Covers the REST endpoint, programmatic callers, and the seeder. + Rejected shapes: missing / `null` / `''` / `{}` / `[]` / `{ $and: [] }` / + unparsable JSON (e.g. a CEL source typed into the Criteria box). + - **The evaluator matches nothing** for such a rule and logs why, so a row + stored before this release under-shares instead of over-sharing: the next + reconcile _revokes_ the grants it had materialised. Both evaluation paths are + covered — the bulk `evaluateRule` and the per-record write-hook path. + - **`bindRuleCriteriaGuard`** fails `sys_sharing_rule` inserts with no + criteria as a field-level `VALIDATION_FAILED` (a 400 naming `criteria_json`), + so the Setup path reports the problem instead of saving an inert rule + (ADR-0078). Updates are checked only when the patch supplies + `criteria_json` — switching an over-broad legacy rule off must not require + inventing a criteria for it first. + - **The seed bootstrap's "empty condition = match-all" branch is gone**: a + missing or empty `condition` is now skipped and logged like any other + non-lowerable one. + - `POST {basePath}/sharing/rules` also accepts `criteria_json` as an alias for + `criteria`, matching the snake_case aliases the endpoint already takes for + `object_name` / `recipient_type` / `access_level`. + + **Migration.** There is no "share every record" sharing rule, and there never + usefully was one — the shape existed only as a failure mode. A rule that + relied on it must state its predicate (`criteria: { stage: 'won' }`), or, if + the object really should be readable by everyone, use the object's + organization-wide default (`sharingModel`) instead. Rules already stored with + a null `criteria_json` need no data migration: they stop granting on the next + evaluation and their existing grants are revoked. + +- c54c822: fix(spec,plugins): sweep the auth/session slot lookups — 31 sites typed, and the user-import metadata reader was pointed at a service that never had the method (#4251) + + Batch B2 of the #4251 sweep: every service-lookup erasure in the auth/session + family. `plugin-auth/auth-plugin.ts` (20), `plugin-hono-server/current-user-endpoints.ts` + (10) and `plugin-security/security-plugin.ts` (1) now pass the slot's contract + type; the ratchet baseline drops **171 → 140 sites, 40 → 37 files**. + + **The yield.** `POST /admin/import-users` resolved the `metadata` slot and probed + `metadataService?.getMetaItem` to decide whether to pass the import's field-coercion + dependency. `getMetaItem` is a **protocol** method — `ObjectStackProtocolImplementation`, + registered by MetadataProtocolPlugin under the `protocol` slot. `MetadataManager`, + which occupies `metadata`, has never had it. So the probe was false on every + deployment and the dep was never passed: imported rows reached `sys_user` + uncoerced, with the branch that says otherwise sitting right there. This is the + same shape as #4127's dead `automation.trigger` and #4321's `registerInMemory` + probes — a capability the code advertises and the runtime cannot deliver, kept + invisible by the `any`. Typing the lookup to `IMetadataService` is what turned it + into a compile error. The route reads `protocol` now. + + `/me/apps` reached ObjectQL's **private** `_registry` through `as any` while + `/auth/me/permissions`, two handlers up in the same file, read the public + `registry` getter over the same field of the same object. Both read the public + accessor now; the one test that stubbed `_registry` was pinning the private reach + and stubs `registry` instead. + + **Contract, from evidence.** `IDataEngine`'s read methods (`find` / `findOne` / + `count` / `aggregate`) declare the trailing `options?: BaseEngineOptions` + argument they have always accepted. ObjectQL's own doc explains why it exists: + reads once took their context inside the query while writes took it in trailing + `options.context`, so the same `{ context }` object was correct as `insert`'s 3rd + argument and **silently dropped** as `find`'s — "an intended `isSystem` bypass + just vanished". The engine accepts both channels; the contract exposed only the + query one, so callers using the trailing channel — the current-user endpoints' + permission-set loader among them — could only reach it by erasing the lookup. + Adding an optional trailing parameter breaks no implementor (the existing + minimal-implementation test proves it) and no caller. `BaseEngineOptions` was + already exported, sitting unused under the "legacy/deprecated" heading, which is + why the contract went looking and did not find it; it moves up beside the other + QueryAST-aligned types with the rationale attached. One new spec test pins the + trailing argument at the call site — the position where the old contract rejected it. + + **Where the contract does not reach, the escape hatch is named.** Three slots + resist a spec type today and each gets a narrow, documented local interface + instead of `any`: `security.permissions` (plugin-security's `PermissionEvaluator` + — plugin-hono-server must not depend on an optional plugin), `settings` + (service-settings' resolver, same reason), and ObjectQL beyond `IDataEngine` + (`registry` / `getSchema` / `registerHook` / `registerMiddleware`). That last one + is deliberate scope: the standing record on `getObjectQL` in `@objectstack/runtime` + says ObjectQL is genuinely wider than `IDataEngine` and nobody has written the + wider contract, so typing the whole thing `IDataEngine` would be "the more + comfortable-looking lie". These declarations are what that contract gets written + from, and what it deletes. + + No behavior changes beyond the two fixes above. + +- 8dcc0f5: fix(spec,runtime): the service-lookup `any` guard now sees the type-argument form, and its scope stops at nothing under `packages/` (#4251) + + The #4127/#4214 rule banned `: any` and `as any` on a service-lookup result but + not `getService('data')` — the form the codebase actually used (80 sites, + zero matches), erasing the slot contract identically. And the rule's `files` + covered only `packages/runtime`, leaving the composition roots (rest, + plugins/_, services/_) that hold most lookups unlinted. Both gaps closed: a + third AST selector catches the type-argument form, the scope is now all of + `packages/`, and the 40 not-yet-swept files are grandfathered in a visible, + shrinking ratchet list (`SLOT_LOOKUP_UNSWEPT`) — enumerated at 180 sites by + running the widened rule with the list emptied. `http.server` joins + `UNCONTRACTED_SLOTS` (three providers, no written contract). + + Typing the three in-scope runtime sites surfaced its first yield: both + `addDatasource` datasource-registration branches (DefaultDatasourcePlugin, + DriverPlugin) probed a method **no metadata service implements**, so they had + never run on any boot — deleted rather than typed against a phantom shape. The + inert `DriverPluginOptions` they configured are tracked in #4320. + `registerInMemory('datasource', …)` is the actual visibility path (#3827). + + Contract members declared from evidence, both optional: `IDataEngine` gains + `getDefaultDriverName?()` / `getDriverByName?()` (ObjectQL's driver registry — + the surface `os migrate` and serve's storage detection reach through + `driver.` services), `IMetadataService` gains `registerInMemory?()` + (MetadataManager's boot-time seeding primitive). Callers that supplied `` + to these lookups should pass the slot's contract type instead — or nothing: + an unmapped slot deliberately resolves to `unknown`, not `any`. + +- 0a2f233: fix(spec): regenerate the ADR-0087 change manifest the RC version bump left stale + + The v17 RC bump moved `PROTOCOL_VERSION` to `17.0.0` but did not re-run + `gen:spec-changes` / `gen:upgrade-guide`, so the published `spec-changes.json` + still declared `protocolVersion: 16.0.0` with no protocol-17 entries — while the + protocol-17 conversions (`execute`→`target`, `conditionalRequired`→`requiredWhen`, + `knowledge.topics`→`sources`, `agent.tools` removal, sharing `full`→`edit`) were + already registered and applied at load. Anything projecting the manifest (the + generated upgrade guide, the `spec_changes` MCP tool) reported a 16-era chain on + a 17 protocol. + + `check:spec-changes` caught it, but only on the first PR to touch a + generated-artifacts path after the bump — this regenerates both projections and + turns the gate green again. `docs/protocol-upgrade-guide.md` now carries the + Protocol 16 → 17 section. + +- 8621cdd: `check:generated` — one command that reports every stale generated artifact, instead of one red build per artifact. + + `packages/spec` has eight checked-in generated artifacts, each with its own gate, split + across two CI jobs that run their gates **sequentially**. So the first stale artifact + masks every one behind it: you fix it, push, and learn about the next one on the + following run. That cost two pushes on #4040 (`check:docs`, then `check:api-surface`) and + two more on #4161 (`check:spec-changes`, then `check:upgrade-guide`) — four round trips + spent discovering something one local run could have said at once. + + - Every gate runs; a failure does not stop the rest. The summary lists all stale + artifacts with the exact `gen:` command for each. + - `--fix` regenerates **only** the artifacts this run proved stale. Deliberately not a + "regenerate everything" button: blanket regeneration rewrites artifacts whose staleness + you never saw, which is how a real semantic change lands silently inside a mechanical + diff. + - The gate → generator ledger is **reconciled against `package.json` on every run**, both + directions, rather than behind a `--self-test` flag. A new `check:`/`gen:` script that + nobody classified fails the run instead of quietly dropping out of coverage — otherwise + the summary would still say "all artifacts up to date" while silently checking fewer, + which is the exact class of lie this script exists to remove. It proved itself + immediately by rejecting its own `package.json` entry on the first run. + - The `check:api-surface` stale-`dist` trap (it reads the built `.d.ts`, so an unbuilt + tree reports every newly-added export as a "breaking removal") is flagged inline when + that gate is the one failing, instead of leaving the next reader to chase a phantom. + - What it deliberately does **not** run is named in the output: the four source audits + with no artifact (`check:liveness`, `check:empty-state`, `check:react-conformance`, + `check:skill-examples`), so "all up to date" never reads as "everything passed". + + It also surfaces a standing gap rather than staying quiet about it: `gen:openapi` and + `gen:sbom` produce artifacts that **no gate verifies**. + + Verified both directions: on a clean built tree all 8 pass; injecting a change into a + migration step's `rationale` makes `check:upgrade-guide` fail and `--fix` regenerates + that one artifact and no other. The same run also showed the two gates have different + inputs — the conversion registry drives `spec-changes.json`, the rationale prose drives + `protocol-upgrade-guide.md` — which the earlier "both failed together" reading had + conflated. + + AGENTS.md's hand-rolled loop over eight hardcoded gate names is replaced by the command; + that list could not survive a ninth artifact, and the script's ledger can. + +- 19365b7: Add a gate that holds the #4001 strictness ledger to the code it describes. + + `docs/audits/2026-07-unknown-key-strictness-ledger.md` is the campaign's map: + which `z.object` sites are authorable (the `.strict()` ratchet target), which are + wire, which are deliberately open. Every step reads it to pick the next move, and + nothing kept it honest. It went stale twice in one week — a classification that + verification disproved (`hook.zod.ts`, #4207) and a "next step" that had shipped + months earlier (the warning layer, #4218). A map that drifts is worse than no + map, because it gets followed. + + `check:strictness-ledger` enforces the claims in it that are mechanically + checkable: + + - **Site counts** — the ledger states its own method (`z.object(` occurrences per + file), so every number is verifiable. A stale count means schemas were added or + removed under a `Class` verdict nobody re-examined. + - **Coverage** — every `*.zod.ts` with sites in a triaged directory must have a + row. Zero-site files (pure enum/token modules) are skipped, and become + reportable the day they grow their first `z.object(`. + - **Section totals**, and that a row claiming "strict as of" names a file that + really contains `.strict()`. + + The `Class` column is deliberately not checked — authorable vs wire vs open is a + human judgement, and the campaign's rule is verify-before-tightening. The gate + protects the arithmetic and coverage so that judgement is made against current + code. + + First run found 11 drifts, including six moved counts (`ui/app.zod.ts` had gone + 11 → 18), two unbalanced section totals, and one genuinely unclassified file + (`automation/io-node-config.zod.ts`). All corrected here. + +- b7ed26d: Datasource unknown-key guidance no longer promises validation that does not happen, and the #4001 strictness ledger sees nested schema directories. + + Two related corrections to the #4001 unknown-key campaign, both about a check reporting more coverage than it had. + + **The `config` prescription was false.** When `DatasourceSchema` rejects a connection detail written at the top level, it prescribed: "Move it to `config: { host: … }`; the driver's own configSchema validates it there." Nothing validates it there — `DriverDefinitionSchema.configSchema` is a `z.record` that both bundled driver specs set to `{}`, and no consumer reads it. The message therefore took an author who had made a recoverable mistake at a place that now catches it and pointed them at a slot where the same mistake is silent again: `config: { hostname: … }` is dropped and the datasource connects on localhost. The guidance now names the per-driver shape to write against (`PostgresConfigSchema` / `MongoConfigSchema` / `MemoryConfigSchema`) instead of promising a gate. Enforcement is tracked in #4410. The same false claim is removed from `data/driver/mongo.zod.ts`, whose header advertised that the Platform validates `datasource.config` with it. + + No authorable key changed — this is error-message and documentation text only. + + **The ledger gate's coverage walk was one level deep.** `check:strictness-ledger` promises that every `*.zod.ts` with sites in a triaged directory carries a classification row. It listed each directory non-recursively, so `data/driver/` — three per-driver config files, nine authorable sites — was invisible to it while it printed "no undeclared schema files". The walk is now recursive (nested files declare as `driver/postgres.zod.ts`), those three files are classified in the ledger, and `scripts/strictness-ledger.test.ts` pins the recursion — necessary because with the rows in place the gate itself passes either way and cannot catch its own regression. + +- 8b50cb3: fix(data): a paged read with no `orderBy` is a partition too — the shape every list view actually sends (#4363) + + objectui#3106's server half closed the **sorted** paged read: a non-empty + `orderBy` now carries a unique tie-breaker, so `ORDER BY status LIMIT 50 OFFSET +50` can no longer serve one row twice while never serving another. It stopped + there deliberately. This closes the half it left, which is the more common one. + + A list view whose metadata configures no `sort`, on which nobody has clicked a + column header, sends no `$orderby` at all. `SqlDriver` and `MongoDBDriver` then + emitted a bare `LIMIT`/`OFFSET` — and neither backend promises anything about + the order that slices: + + - **SQL** leaves the row order of an unordered read to the plan. Small tables + hand back insertion order in practice, which is exactly why this survives + testing; a parallel scan, an index scan, or a `VACUUM` need not. + - **MongoDB** returns natural order, which describes where a document currently + sits in its extent — and moves when the document does. + + Every row ties with every other on an empty sort key, so this is the same defect + at full strength rather than a different one: page 2 repeats a row page 1 showed + and drops one nobody sees, with every page full and every row real. + + Both drivers now order a paged read by their unique key column when the caller + supplied no sort keys — the same `id` the tie-breaker was already appending, now + standing alone. `driver-memory` again needed no change: it slices its backing + array, and two reads with no write between them see the identical sequence. The + contract asks for a partition, not for id order. + + **Unpaged reads are untouched, deliberately.** The rule keys off `limit`/ + `offset`, not off `orderBy` being absent. A read with neither hands back the + whole matching set, so no caller can be shown a partial view of it, and sorting + every read in the system would change plan selection to buy nothing. `limit` + alone does count as paged: page one of a walk is routinely `limit=50` with no + offset, and ordering only the later pages would leave the defect fully intact. + + `SqlDriver` keeps the existing restriction to objects it created itself + (`initObjects` records them). It matters more here than for the sorted case: on + a federated table (ADR-0015) there is no requested sort for #3821's ladder to + fall back to, so a wrong guess about `id` would turn a reshuffle into a failed + read. Those tables now get a warning — once per object, behavior unchanged — + because the contract states determinism as a MUST, and a MUST that quietly does + not hold is the same invisible failure the rule was written against. + + `findOne` is deliberately outside all of this, and the contract now says so. + Engines reach a driver with `limit: 1`, which is shaped exactly like page one of + a walk, but it promises _a_ matching record rather than a position in a + sequence — nothing for a second call to be inconsistent with. Reading it as a + page would put `ORDER BY id LIMIT 1` on the hottest read in the system, which is + the classic shape for a planner to abandon the predicate's own index: measured + on Postgres 16 over 2M rows, `WHERE owner_id = ? LIMIT 1` went 0.08 ms → 7.8 ms + and swapped the `owner_id` index for the primary key. `MongoDBDriver.findOne` + has never sorted, so this also puts the two drivers back in step. + + The obligation is normative on `IDataDriver.find` and the cases are shared — + `PAGINATION_UNORDERED_CASES` alongside `PAGINATION_CASES` in + `@objectstack/spec/data` — so a future driver is held to both halves by a gate + rather than by remembering. + +- 8c2db68: Add a gate that fails CI when a discriminated-union variant the schema declares is + never mentioned in the hand-written doc bound to it. + + The liveness gate asks whether a property does anything; `check-doc-authoring` asks + whether docs use the right authoring form. Neither asked the inverse-drift question: + does a variant the schema declares appear in the prose at all? `content/docs/references/` + is generated from the schemas and cannot drift, but the hand-written pages are typed by + humans and do. + + The founding case shipped for months: `content/docs/ui/apps.mdx` said the navigation + tree "supports eight item types" and enumerated eight. The schema had nine — `separator` + was added to match the objectui renderer and no hand-written page ever learned about it. + Making `NavigationItemSchema` a discriminated union raised the cost of that gap, because + a mistyped `type` now answers with the list of valid discriminators and the doc's + enumeration is what an author checks it against. + + `packages/spec/variant-docs.json` classifies all 20 unions: 5 governed (every variant + must be mentioned in a bound doc), 15 exempt as either generated-reference-only or + not-authorable. The ledger key is the discriminator plus its sorted variant set, so + adding or removing a variant changes the key and sends the author back through the + ledger — and therefore back to the doc. + +- 22b5e54: The variant/doc gate's coverage matcher now recognises a YAML mapping line (`type: npm`), not only the quoted (`type: 'npm'`) and backticked (`` `npm` ``) forms. + + Found by auditing the ledger's seven `generated-reference-only` exemptions — a class that does not say "this variant is un-authorable", only "nobody wrote a guide for it". One of the seven was not a doc gap at all: `protocol/objectui/widget-contract.mdx` has documented `inline` / `npm` / `remote` in a "Widget Source" section the whole time, in YAML examples the matcher could not see. That entry is now governed, so the page is under the ratchet. Two more (tenant isolation strategy, settings-manifest handler) were reclassified `not-authorable` — their own reasons already said `operator-set` and `consumed by Setup/Studio`. The remaining exemptions now state their gap plainly instead of pointing at the generated reference as if it settled the question; connector authentication is flagged as the one worth acting on, being tenant-authored (ADR-0097) with no hand-written connector guide anywhere in the repo. + + The YAML form is anchored to a full line (trailing `# comment` allowed), so it stays as tight as the quoted form — a bare word in prose still does not count. + +- 9b702dc: The `wait` executor reads its declared contract only; the loose `config` back door graduates into the conversion layer (#4045). + + `wait` keeps its contract in `waitEventConfig` — a declared, `.describe()`-annotated + block on `FlowNodeSchema` that is in the authorable-field list, reaches the generated + reference, and is what the showcase actually authors. Its descriptor publishes no + `configSchema`, which is by design rather than the gap it first looks like. + + The executor nevertheless also read six loose `config` keys behind `wec.X ?? loose.X`, + two of them (`duration`, `signal`) spellings the spec never declared anywhere. That is + the `notify.source` shape #4050 retired: a second de-facto contract announced only by a + code comment, so an author who wrote it got a flow that worked forever and was never + steered to the declared spelling (PD #12). Not hypothetical: the showcase's own + `wait_revision` node authored it (`config: { eventType: 'signal', signalName: … }`) and + moves to the declared block here. + + - New ADR-0087 D2 conversion `flow-node-wait-event-config-lift` lifts + `config.{eventType,timerDuration,duration,timeoutMs,signalName,signal}` onto the + declared `waitEventConfig` block, in the executor's own `??` precedence — a declared + value wins and its loose counterpart is left shadowed, exactly as `renameConfigKey` + treats a shadowed alias. + - `eventType` is stamped `'timer'` whenever the lift would otherwise leave the block + without one. This is load-bearing: the loader parses the **converted** flow + (`applyConversionsToFlow` → `FlowSchema.parse`) and `waitEventConfig.eventType` is + required once the block exists — so a stored flow carrying only + `config: { duration: 'PT1M' }` would have gone from working to failing to load. + `'timer'` is the exact default the executor applied to that shape. + - The executor's six `?? loose.*` fallbacks are deleted. The surviving `?? 'timer'` is + not one: `waitEventConfig` is itself optional, and a wait node without one is a valid + timer wait. + + Verified at the real seam: the new executor tests author the legacy shape and go through + `registerFlow`, which is what applies the conversion, so they prove the graduation + end-to-end on a legacy source rather than only that the executor stopped looking. A + negative control pins the `eventType` default — deleting it from the converted output + makes `FlowSchema.parse` throw. + + Two things this deliberately does **not** change, filed as #4158 rather than fixed in + passing: `waitEventConfig.timeoutMs` is declared as a timeout guard but read as a timer + duration, and `waitEventConfig.onTimeout` has zero readers anywhere — so `wait` has no + timeout implementation at all, while the showcase authors `onTimeout: 'continue'`. + Implementing or retracting that is a behaviour change, not a contract cleanup. + ## 17.0.0-rc.0 ### Major Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 9bdf810122..e432888031 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index b68cf7b2ff..eb6cdcc225 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,268 @@ # @objectstack/trigger-api +## 17.0.0-rc.1 + +### Minor Changes + +- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003) + + Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed + the set; this batch moves the emitters that still spoke lowercase `snake_case` + onto it. + + **Wire-visible change.** Error codes on these surfaces change spelling. Generic + conditions collapse onto the standard catalog rather than keeping a synonym: + `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` → + `PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` → + `INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` → + `NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes + registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`, + `PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept: + `cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`, + `service-messaging`, `service-automation`, `trigger-api`. + + Branch on `error.code` values rather than pattern-matching their case: the + console's fix for the same rename (objectui#2977) reads codes case-insensitively + for exactly this reason, and that is the pattern to copy in your own consumers if + you support servers on both sides of the change. + + **Four routes stop putting a code in the message slot.** The webhook redeliver + route, the API-trigger webhook, and two `rest` routes answered + `{ success: false, error: '', message }` — the code occupying `error`, the + declared object envelope nowhere. They now emit `error: { code, message }`, and + three API-trigger branches gained a message they never had. Clients reading + `body.error` as a string on those routes must read `body.error.code`. + + **`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a): + `@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and + two `RetryStrategy` types. The connector-side pair is renamed; importers of the + `integration` subpath update the name. Side effect: the api-side `ErrorCategory` + and `RetryStrategy` now appear in the generated API reference at all — the name + collision had been silently dropping them. + + **`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client + registration route put better-auth's arbitrary `body.error` string straight into + `error.code`. The code is now ours and the upstream discriminator moved to + `details.upstreamError`. + + **Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values + (ADR-0112 D6b): it is persisted audit history, and the same column holds + non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a + 200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's + `--json` output contract. + + A `check:error-code-casing` CI guard now fails on a new lowercase literal in a + code position, since the ledger's casing rule can only police codes that someone + registers. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248) + + These 20 packages declared no `files` field, so npm fell back to packing the + whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks` + listed **21 files** — 15 under `src/`, three of them unit tests + (`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the + build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at + publish time rather than instead of it, so consumers were installing the + TypeScript sources and the test suite alongside the artifact they asked for. + + Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages + that already did. Nothing a consumer imports moves: every `main` / `types` / + `exports` target in all 20 already resolved inside `dist/`, which the new + `check:published-files` guard verifies rather than assumes. The visible change + is a smaller install and a smaller dependency-scanning surface — `npm pack` on + `@objectstack/plugin-webhooks` now yields 2 files plus `dist/`. + + The other half of the fix is the gate. Half the packages declaring `files` and + half not was the #3786 shape — a hand-copied convention with nothing enforcing + it, where whoever forgets the line gets no signal at all. `check:published-files` + (new, wired into the always-required `lint` job) holds every non-private + workspace package to four invariants: `files` is **declared**; it is + **sufficient** (covers every entry point, so tightening a whitelist cannot ship + a package that fails to resolve); it is **minimal** (admits no test, test-harness + config or build script); and anything beyond `dist` + `README.md` is + **registered** with a reason, reconciled in both directions so a stale exemption + is an error rather than dead text. `@objectstack/spec` is the one package with + registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and + `CHANGELOG.md` are product, not build input. + + This also closes an assumption #4206 was resting on. Excluding `/scripts/**` + from the docs-drift implementation test is sound only while no package publishes + `scripts/` as runtime code; that held, but it held because someone read all three + offenders by hand. It is now checked on every PR. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 21ed12f2ce..4a89263ab8 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 269fae377b..03895cf920 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,180 @@ # @objectstack/plugin-trigger-record-change +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 893bfd1a92..bfa23a801f 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 36d45394df..069b9d7c64 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,180 @@ # @objectstack/plugin-trigger-schedule +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [2e836de] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 9fa828e894..1ab493e71d 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 4ae5411c08..3a4c7dbc8c 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,440 @@ # @objectstack/types +## 17.0.0-rc.1 + +### Minor Changes + +- 2a37694: fix(plugin-dev,types): the production escape hatch stops being silent (#3900) + + `DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and + `OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned + early with **no output at all**: the process ran the development assembly while + every log line and the ready banner read like an ordinary production start. + + That reproduces, one level up, the defect the guard exists to close. The guard's + own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded _and brands + it everywhere an operator looks_, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s + contract is "logged loudly at startup". An escape hatch that says nothing leaves + the operator's only evidence of a degraded state in an env var they may not have + set themselves. + + **The override now brands itself, twice.** A warning at `init()` — emitted + before any assembly work, so it survives an assembly step that later throws — + and a repeat on the ready banner, which is the surface an operator actually + reads: + + ``` + ⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot + guard was explicitly overridden. This process is running the DEVELOPMENT + assembly, which is not hardened for production traffic (ADR-0115 D6). + • Auth secret is the default published inside @objectstack/plugin-dev. It is + public, so anyone can mint a session this stack accepts. Pass `authSecret` + explicitly. + • Data goes to the in-memory driver with persistence disabled — every record + is lost when this process exits. + ``` + + Only hazards that are live for _that_ configuration are named: the secret line + is suppressed when the operator passed their own `authSecret`, and the driver + line when the `driver` toggle is off. The dev-admin seed is deliberately absent + — `plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to + `NODE_ENV === 'development'` and cannot fire on this path, so warning about it + would spend the attention the real hazards need. + + **New export — `resolveAllowDevPlugin()` (`@objectstack/types`).** The flag moves + off a bare `process.env['OS_ALLOW_DEV_PLUGIN'] === '1'` and joins the + `OS_ALLOW_*` family's shared truthy vocabulary, next to + `resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`. + + FROM → TO for operators: `OS_ALLOW_DEV_PLUGIN=1` keeps working unchanged. + `OS_ALLOW_DEV_PLUGIN=true` (and `on` / `yes`, case-insensitive, surrounding + whitespace ignored) **now takes effect** where the strict comparison previously + ignored it and failed the boot. That is a widening, in the direction an operator + setting the flag already intended; falsy and unrecognised values still refuse to + boot, and unset still means "fail fast". If you were relying on + `OS_ALLOW_DEV_PLUGIN=true` being inert as a way to keep the guard armed, unset + the variable instead. + + No change to the refusal path, which this issue re-verified end to end: + `kernel.use()` only registers, `initPluginWithTimeout` does not catch, + `bootstrap()` rethrows, and `os serve`'s outer handler prints the message and + exits `1`. The `throw` is genuinely fatal here, so it needs none of the + `process.exit(1)` the tenancy guard required for sitting inside a broad `catch`. + +- d5749d7: refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973) + + `BaseResponseSchema` declares one envelope for every REST body the platform + emits. It declared it once; the code that _wrote_ it was copied per route + module. After #3843 and #3983 converted the last drifting one, seven modules + each carried their own two-line `sendOk` / `sendError` pair — so the envelope's + shape lived in fourteen places rather than one. + + `pnpm check:route-envelope` proved those seven copies agreed, which is why this + is a cleanup rather than a bug fix. But a guard proves agreement; it does not + create it. An eighth module starts by copying the pair again — not + hypothetically: `share-link-routes.ts` was found already drifting by the + repo-wide scan, and its drift had broken `client.shareLinks.create()` and + `.list()` through `unwrapResponse` (#3983). + + ## What moved + + `sendOk` / `sendError` now live once, in `@objectstack/types` + (`response-envelope.ts`), and all seven modules import them: + + | Module | + | ------------------------------------- | + | `service-storage/storage-routes.ts` | + | `service-settings/settings-routes.ts` | + | `service-datasource/admin-routes.ts` | + | `rest/external-datasource-routes.ts` | + | `rest/package-routes.ts` | + | `service-i18n/i18n-service-plugin.ts` | + | `plugin-sharing/share-link-routes.ts` | + + Placement was the open question in #3973, not design. `packages/spec` is + schemas-only (Prime Directive #2), and the callers span `rest`, four + `services/*` and one `plugins/*`, which rules out anything depending on them. + `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller + can reach it, and it is already where the repo puts a helper the HTTP boundaries + share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the + same argument first. + + The builders take a structural `{ status(n), json(body) }`, so the package + imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the + `any`-typed `res` the older modules carry. + + ## `error.code` is now checked by the compiler + + All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the + vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an + invented code was still caught only at runtime, by a conformance suite parsing a + driven body, i.e. only on routes some test happened to drive. + + The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now + fails to compile, at every call site at once: + + ```ts + sendError(res, 400, "NOT_A_REGISTERED_CODE", "invented"); + // Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'. + ``` + + This cost no call-site churn: every code the seven modules emit was already + registered. + + ## `extra` is closed at the same place + + `sendError`'s last parameter is `Pick` — exactly what `ApiErrorSchema` declares beside `code` + and `message`. + + It was `Record` while `settings-routes` still hung `namespace` / + `key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway: + `ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than + rejected, and `envelopeViolations` inspects only the body's top level — + conformant _by stripping_ rather than by declaration. #4224 moved that module + onto `details`, which is what lets the parameter close here. Closing it at the + shared builder is the part that lasts: an undeclared sibling is now a compile + error in every module at once, rather than a key that quietly evaporates in + whichever module reintroduces it. + + ## Nothing changes on the wire + + The seven pairs were identical modulo the optional `status` and `extra` + parameters this one unions, and each module's driven conformance suite still + parses its real bodies against the real spec schemas. One internal call site was + rewritten: `package-routes` passed `details` positionally and now passes + `{ details }`, producing the same `error.details` it always did. + + ## The guard got stronger + + `scripts/check-route-envelope.mjs` counts response write sites per module. A + module that routes everything through the shared pair builds **none** itself, so + the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the + shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for + the surface rather than per-module. What the count asserts is no longer "your two + builders are the enveloped ones" but "you have no builders" — and a new route + that hand-rolls a body still moves it off zero and fails. + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- c20b875: **Correct the stale premise left behind by #4012: the degraded-boot stderr copy + survives the operator's LOG LEVEL, not `os serve`'s boot-quiet window.** + + `emitDegradedBootBanner` writes the `OS_ALLOW_DRIVER_CONNECT_FAILURE` banner to + stderr in addition to `logger.warn`, and every comment and test name explaining + why cited the same reason: `os serve` swallowed all of stdout while the kernel + booted, and `Logger` routes `warn` to stdout. #4012 fixed that — the boot window + now buffers and replays `warn`-and-above — which retires the _stated_ + justification for a duplicate that is nonetheless still load-bearing: + + `Logger.write()` returns before touching a stream when the record is below + `config.level`, so at `--log-level error`, `fatal` or `silent` the banner's + `logger.warn` reaches **no** stream at all. A production host at `error` is + exactly the deployment this escape hatch exists for, and exactly where a + logger-only banner would vanish. Removing the stderr copy on the strength of + #4012 would therefore have been a regression — so this documents the reason that + is still true, in the places someone would read before deleting it: + `degraded-boot.ts`, the engine's emit site, and all three parity tests + (objectql, runtime, service-datasource), which are renamed off "which `os serve` + boot-quiet cannot swallow" to "which the operator log level cannot filter away". + + The objectql parity test now proves the claim instead of asserting around it: it + drives a **real** `ObjectLogger` at `level: 'error'` and requires the banner on + stderr _and_ nothing on stdout. Set the level to `warn` and it fails — so the + test is pinned to the level filter rather than passing for any reason. + + Also corrected in the same sweep, all comment-only, all previously overstating + what #4012 had not yet fixed: + + - the automation wiring summary (`format.ts`, `serve.ts`, its test) claimed the + boot window swallowed the engine's binding warnings. Its real justification is + stronger and unchanged: a flow that silently fails to arm emits **no** log line + at any level, so binding state has to be read off the live engine — absence of + a warning was never evidence of a bound flow. + - the seed summary (`seed-summary.ts`, `format.ts`, its test) and `AppPlugin`'s + seed-outcome note attributed the silence to the boot window; the operative + gate is that `SeedLoader`'s result logs are `info`, under the default `warn`. + + No behavior changes. + +- 9881074: fix(batch): the background walks seek instead of counting, so they stop skipping rows (#4363) + + #4363 made a single paged read a partition of its result set. It could not make + a _walk_ one: seven background scans paged with a growing `offset` while writing + to the very rows they were reading, and an offset counts into a set those writes + are changing. Rows slide past the cursor and are never visited. + + That is not a slow page in any of these — it is a wrong answer wearing the shape + of a clean run: + + - **`rebuildApproverIndex`** built its desired state by walking + `sys_approval_request WHERE status = 'pending'` with no `orderBy` at all, then + **deleted** every index row that state did not explain. A skipped request + meant an approver silently dropped from someone's queue. (The loop beside it + ordered by `created_at` — not unique, so its pages were never a partition + either.) + - **`verifyFileReferences`** decides which files nothing references. A record it + never visits is reported as an unreferenced file. + - **`backfillFileReferences`** and the **pinyin companion backfill** rewrite + each row they read, so their own writes were shifting the set out from under + the cursor. Records were left unconverted and unsearchable by a run that + reported success. + - **`scanValueShapes`** exists to vouch that no stored value is off-shape, and + it opens a migration gate on that evidence. + + All of them now go through `keysetWalk` (`@objectstack/types`): order by a + unique key, and seek past the last one instead of counting from the start. A + row's key does not move when the row is updated, and cannot be shifted when + another is deleted, so the walk is stable under exactly the mutation these + functions perform. It is also O(n) rather than O(n²/page) — measured on + Postgres over 2M rows, deep pages cost ~1.1 s by offset against ~0.09 s by seek. + + One deliberate non-conversion: the REST **export** stream keeps its offset. It + honors a caller-chosen sort, and a keyset walk would have to re-order the export + by `id` to seek — changing what the user asked for to fix a cost. Its pages are + already a partition since #4363; only the depth cost remains. + + `keysetWalk` merges the cursor with `$and` rather than spreading it into the + caller's filter, so a walk whose own `where` constrains the key column + (`{ id: { $in: [...] } }`) keeps that constraint instead of having it silently + overwritten. When a `max` cap is set it reads one row beyond the cap to tell + "the cap stopped us" from "the source ended exactly there" — without that, a + walk that read everything still reports `truncated`, and a caller acting on it + goes looking for rows that were never withheld. + + The storage suites' fake engines now **throw** on an `offset` instead of serving + one, so the conversion is pinned rather than merely passing. + +- 39eb01b: fix(runtime,cli,types): `os migrate` and the dev runtime now share one `__search` companion schema view (#3955) + + On a zh-locale deployment the dev runtime provisions the hidden `__search` + pinyin companion column (ADR-0098) on every eligible object, but the + `os migrate plan`/`apply` boot went through `createStandaloneStack`, which + never derived the locale-gated pinyin decision from the compiled artifact. + Its metadata therefore lacked every companion column, and `migrate plan` + reported each live `__search` column of a dev-created database as a + destructive orphan — with `--allow-destructive` as the printed remediation, + which would have dropped live feature columns. + + - `@objectstack/types`: new `collectConfiguredLocales(i18n)` and + `stampSearchPinyinEnabled(i18n)` — the single resolve-and-stamp helper for + `OS_SEARCH_PINYIN_ENABLED`. An explicit env value still wins; only a + positive locale-derived decision is stamped. + - `@objectstack/runtime`: `createStandaloneStack` stamps the decision from + the artifact's `i18n` before any plugin constructs a `SchemaRegistry`, and + surfaces `i18n` on its result like `requires`/`objects`/`manifest`. + - `@objectstack/cli`: the `serve`/`dev` boot now stamps through the same + shared helper (behaviour unchanged), so create/serve and plan/apply cannot + compute different schema views of the same source tree. + + A fresh CLI-created database is now also born with the same `__search` + columns the dev runtime would provision, instead of acquiring them on the + next dev boot. + +- Updated dependencies [6a67d7a] +- Updated dependencies [0ecc656] +- Updated dependencies [06772eb] +- Updated dependencies [270650f] +- Updated dependencies [3aef718] +- Updated dependencies [1ea6bce] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [05154a1] +- Updated dependencies [9b6fe7c] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [2af1988] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [c8124e5] +- Updated dependencies [a1a4140] +- Updated dependencies [217e2e6] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [dc530b4] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [7309c81] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [af2a095] +- Updated dependencies [ec796d5] +- Updated dependencies [e87fea1] +- Updated dependencies [c65e529] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [65a3a84] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/spec@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 0a1056cab5..326d52cffb 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 ab536a1377..2877d25465 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,354 @@ # @objectstack/verify +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + +- c3bcb42: feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826) + + ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question + open: a host whose `default` needs a driver the shared factory cannot build — + the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the + cloud control-plane driver doubles as the proxy base of every environment + kernel; per-environment drivers are cached across kernel rebuilds) — had only + two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose + connect verdict lives in `ObjectQLEngine.init()` (the second implementation + #3826 exists to retire), or fork the connect orchestration. Either re-opens the + #3741 → #3758 drift this whole line of work is about. + + Two additive pieces close it: + + - **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`** + (defaults to the shared open-core factory, byte-for-byte unchanged when + omitted). The factory only changes what `create()` returns — the policy-free + init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE` + escape hatch, and the start() replay into retained admin state are identical + either way, and the new tests pin that (an adopted instance that cannot + connect takes the exact same verdict). + - **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in + `@objectstack/service-datasource` — the "adopt an existing driver" seam the + first #3826 pass found missing, landed AS a factory so it composes into the + one connect path instead of becoming a second entry point. `create()` returns + the SAME instance every call: construction, pooling, and reuse stay host + concerns; only the verdict converges. Not for the common case — a `default` + expressible as `{ driver, config }` should stay a plain definition. + + The `@objectstack/verify` dogfood harness now boots through + `DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a + pre-built `DriverPlugin` — so the dogfood gate exercises the same declared + -default connect path `objectstack dev`/`serve` use, which is the §Risk + mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have. + The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is + still live for the boot re-verification, `DriverPlugin` escape-hatch drivers, + and the cloud compositions until they converge onto this seam. + +- 64f8cbe: feat(platform-objects,service-settings,verify): `sys_secret` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the settings service (#4270) + + The environment's encrypted-secret store (`sys_secret`, ADR-0066 D2/④) was + registered by `@objectstack/service-settings`, but it has three producer + classes and only one of them is settings: the settings service's encrypted + specifiers, the ObjectQL engine's own `secret`-field encryption + (`encryptSecretFields`/`resolveSecret` — the generic write path of ANY + business object carrying a `Field.secret()`), and the datasource credential + binder. Unlike the `sys_migration` precedent (#4243), the failure posture is + fail-CLOSED: on a kernel composed without settings, every insert/update of an + object with a secret field threw — with an error message that told the + operator to "Ensure the platform-objects (sys_secret) are registered", naming + a package that did not register it. + + The registration now lives in `PlatformObjectsPlugin` + (`@objectstack/platform-objects/plugin`) — the plugin `os serve` already + auto-injects into every served kernel — so the store exists with the + platform, independent of which optional services are composed, and the + engine's fail-closed error message is true. Definition ownership is unchanged + (`sys_secret` stays in `@objectstack/platform-objects` and in + `PLATFORM_OBJECTS_BY_PACKAGE`); the settings service remains a producer and + consumer through its `sys_secret`-backed secret store. + + Consequences: + + - `@objectstack/service-settings` no longer contributes `sys_secret` to the + manifest (`settingsObjects` is now `[SysSetting, SysSettingAudit]`). An + embedder composing `SettingsServicePlugin` on a hand-built kernel that + relied on it for the `sys_secret` table must compose + `PlatformObjectsPlugin` (the plugin every supported assembly path already + includes). The move REPLACES the registration — nothing registers the + object twice. + - `@objectstack/verify`'s boot harness now composes `PlatformObjectsPlugin`, + mirroring `os serve`'s auto-inject — which also means harness kernels now + carry the `sys_migration` ledger + fresh-datastore attestation (#4243) the + served assembly always had. + +- Updated dependencies [bc35e00] +- Updated dependencies [6a67d7a] +- Updated dependencies [6e141bc] +- Updated dependencies [48fcf70] +- Updated dependencies [0ecc656] +- Updated dependencies [a4e2684] +- Updated dependencies [06772eb] +- Updated dependencies [0c90ece] +- Updated dependencies [195ad76] +- Updated dependencies [c2bbd97] +- Updated dependencies [3ec8186] +- Updated dependencies [698cbc2] +- Updated dependencies [b1863a5] +- Updated dependencies [270650f] +- Updated dependencies [956e7f9] +- Updated dependencies [3aef718] +- Updated dependencies [ffb003c] +- Updated dependencies [1ea6bce] +- Updated dependencies [e5e8b10] +- Updated dependencies [c1dcacd] +- Updated dependencies [ad303ed] +- Updated dependencies [32ccb23] +- Updated dependencies [f5a4ef0] +- Updated dependencies [2d3e255] +- Updated dependencies [7d7521f] +- Updated dependencies [5dc4d02] +- Updated dependencies [bb1ce2e] +- Updated dependencies [b4be309] +- Updated dependencies [6fa1827] +- Updated dependencies [05154a1] +- Updated dependencies [7a55913] +- Updated dependencies [0f12193] +- Updated dependencies [7a55913] +- Updated dependencies [f5ab1c7] +- Updated dependencies [9b6fe7c] +- Updated dependencies [3abd233] +- Updated dependencies [8c711fb] +- Updated dependencies [09e4547] +- Updated dependencies [91f4c78] +- Updated dependencies [820eff9] +- Updated dependencies [8d895ff] +- Updated dependencies [ea24593] +- Updated dependencies [f6472d7] +- Updated dependencies [78caf51] +- Updated dependencies [62a789b] +- Updated dependencies [789ad63] +- Updated dependencies [fccec22] +- Updated dependencies [2af1988] +- Updated dependencies [0af50a3] +- Updated dependencies [fce14ab] +- Updated dependencies [2e836de] +- Updated dependencies [7309c81] +- Updated dependencies [12a19a8] +- Updated dependencies [41dcda3] +- Updated dependencies [7df7c64] +- Updated dependencies [fae74b5] +- Updated dependencies [545d931] +- Updated dependencies [a225ef5] +- Updated dependencies [7bf5349] +- Updated dependencies [366105c] +- Updated dependencies [c9d254a] +- Updated dependencies [c8124e5] +- Updated dependencies [c3bcb42] +- Updated dependencies [a1a4140] +- Updated dependencies [c20b875] +- Updated dependencies [f4d7f1d] +- Updated dependencies [217e2e6] +- Updated dependencies [4dc14cc] +- Updated dependencies [0373d52] +- Updated dependencies [4f30943] +- Updated dependencies [86a71d1] +- Updated dependencies [d5c75e2] +- Updated dependencies [03d26f7] +- Updated dependencies [bb192c4] +- Updated dependencies [98e7cc7] +- Updated dependencies [4cf7c61] +- Updated dependencies [4384921] +- Updated dependencies [3c628ce] +- Updated dependencies [347f460] +- Updated dependencies [8a341a4] +- Updated dependencies [7cb922e] +- Updated dependencies [1d22114] +- Updated dependencies [b5f9397] +- Updated dependencies [ed77493] +- Updated dependencies [58a03d2] +- Updated dependencies [c39d713] +- Updated dependencies [dc530b4] +- Updated dependencies [f0d6594] +- Updated dependencies [e59786e] +- Updated dependencies [bcf1112] +- Updated dependencies [9774b78] +- Updated dependencies [385c4b0] +- Updated dependencies [b07d829] +- Updated dependencies [a648e96] +- Updated dependencies [a47ac06] +- Updated dependencies [e4c61a7] +- Updated dependencies [cc60165] +- Updated dependencies [081aa6f] +- Updated dependencies [91f4c78] +- Updated dependencies [e8d0c21] +- Updated dependencies [45dc446] +- Updated dependencies [d4720ca] +- Updated dependencies [43ff598] +- Updated dependencies [e5a4d26] +- Updated dependencies [839982e] +- Updated dependencies [623e555] +- Updated dependencies [c1d44f7] +- Updated dependencies [ab9fb5c] +- Updated dependencies [f985b3f] +- Updated dependencies [9a4932a] +- Updated dependencies [71af9f5] +- Updated dependencies [f9fc874] +- Updated dependencies [011b386] +- Updated dependencies [9881074] +- Updated dependencies [7777e8f] +- Updated dependencies [507b92a] +- Updated dependencies [99b4392] +- Updated dependencies [99ffc04] +- Updated dependencies [974c6d4] +- Updated dependencies [7309c81] +- Updated dependencies [495019b] +- Updated dependencies [20bc1ec] +- Updated dependencies [90c2b15] +- Updated dependencies [33a5ff4] +- Updated dependencies [39eb01b] +- Updated dependencies [42eeb7d] +- Updated dependencies [01e124d] +- Updated dependencies [55bbefc] +- Updated dependencies [7ce02eb] +- Updated dependencies [a13827e] +- Updated dependencies [7733604] +- Updated dependencies [40e420f] +- Updated dependencies [d13004a] +- Updated dependencies [be7360c] +- Updated dependencies [be7945a] +- Updated dependencies [cc2de0e] +- Updated dependencies [5b47ab5] +- Updated dependencies [b09d8d9] +- Updated dependencies [b09d8d9] +- Updated dependencies [8675db6] +- Updated dependencies [b09d8d9] +- Updated dependencies [3eb1b2b] +- Updated dependencies [59b85c0] +- Updated dependencies [6e357ed] +- Updated dependencies [d6938bf] +- Updated dependencies [31e0be9] +- Updated dependencies [4bfd455] +- Updated dependencies [ffd2ce2] +- Updated dependencies [62f8017] +- Updated dependencies [a831df1] +- Updated dependencies [f752ee3] +- Updated dependencies [a1b61e0] +- Updated dependencies [cd6b9f2] +- Updated dependencies [2cb6d3c] +- Updated dependencies [3ba8d77] +- Updated dependencies [6c87cc9] +- Updated dependencies [af2a095] +- Updated dependencies [bf478e1] +- Updated dependencies [dd5daac] +- Updated dependencies [ec796d5] +- Updated dependencies [77fadbf] +- Updated dependencies [a3cb9c8] +- Updated dependencies [e87fea1] +- Updated dependencies [4be9d99] +- Updated dependencies [c65e529] +- Updated dependencies [8dcc0f5] +- Updated dependencies [5b08389] +- Updated dependencies [3ca34c1] +- Updated dependencies [239c3a3] +- Updated dependencies [94a0bbc] +- Updated dependencies [d6bfb3d] +- Updated dependencies [0931185] +- Updated dependencies [a2266a6] +- Updated dependencies [d25a0ec] +- Updated dependencies [5c13368] +- Updated dependencies [1d5dc46] +- Updated dependencies [667b83e] +- Updated dependencies [627b188] +- Updated dependencies [8d4eae7] +- Updated dependencies [857a6cf] +- Updated dependencies [1e38158] +- Updated dependencies [65a3a84] +- Updated dependencies [f1f40b4] +- Updated dependencies [4580597] +- Updated dependencies [de6daa5] +- Updated dependencies [d5749d7] +- Updated dependencies [ccd9397] +- Updated dependencies [bca935b] +- Updated dependencies [d92c72d] +- Updated dependencies [c54c822] +- Updated dependencies [8dcc0f5] +- Updated dependencies [75b9e51] +- Updated dependencies [0a2f233] +- Updated dependencies [8621cdd] +- Updated dependencies [6f23667] +- Updated dependencies [77a77fd] +- Updated dependencies [d82f8c0] +- Updated dependencies [5d21a48] +- Updated dependencies [19365b7] +- Updated dependencies [b7ed26d] +- Updated dependencies [2053714] +- Updated dependencies [68dea0b] +- Updated dependencies [64f8cbe] +- Updated dependencies [b3a3d83] +- Updated dependencies [7a55913] +- Updated dependencies [35accbf] +- Updated dependencies [6038de7] +- Updated dependencies [7309c81] +- Updated dependencies [eb95d97] +- Updated dependencies [e4c2dc8] +- Updated dependencies [43fc039] +- Updated dependencies [1bd2795] +- Updated dependencies [8186a70] +- Updated dependencies [a329cca] +- Updated dependencies [6eec18c] +- Updated dependencies [4d7bebf] +- Updated dependencies [821ac7a] +- Updated dependencies [8f81731] +- Updated dependencies [4965bfa] +- Updated dependencies [8b50cb3] +- Updated dependencies [8c2db68] +- Updated dependencies [22b5e54] +- Updated dependencies [0166bd5] +- Updated dependencies [9b702dc] +- Updated dependencies [ab16331] + - @objectstack/runtime@17.0.0-rc.1 + - @objectstack/spec@17.0.0-rc.1 + - @objectstack/objectql@17.0.0-rc.1 + - @objectstack/platform-objects@17.0.0-rc.1 + - @objectstack/plugin-sharing@17.0.0-rc.1 + - @objectstack/plugin-security@17.0.0-rc.1 + - @objectstack/rest@17.0.0-rc.1 + - @objectstack/core@17.0.0-rc.1 + - @objectstack/plugin-auth@17.0.0-rc.1 + - @objectstack/service-automation@17.0.0-rc.1 + - @objectstack/service-analytics@17.0.0-rc.1 + - @objectstack/plugin-hono-server@17.0.0-rc.1 + - @objectstack/service-datasource@17.0.0-rc.1 + - @objectstack/service-settings@17.0.0-rc.1 + ## 17.0.0-rc.0 ### Minor Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index f845d08ff6..468e1ad038 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "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 99f22fb719..fb0f0de3f9 100644 --- a/packages/vscode-objectstack/CHANGELOG.md +++ b/packages/vscode-objectstack/CHANGELOG.md @@ -1,5 +1,39 @@ # objectstack-vscode +## 17.0.0-rc.1 + +### Patch Changes + +- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261) + + The AGENTS.md post-task checklist requires breaking changesets to carry their + FROM → TO migration because "this text ships to consumers as `CHANGELOG.md` + inside the npm package and is what an upgrading agent greps after the tombstone + error." That delivery path was severed for 68 of the 69 publishable packages: + npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike + older npm versions — not `CHANGELOG.md`, and the canonical + `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm + 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its + 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it + explicitly. + + The tombstone-error scenario is precisely the one where the repo is out of + reach — the upgrading agent has `node_modules` and nothing else — so the + migration text has to ride in the tarball. Every publishable package now + declares `CHANGELOG.md` in `files`, and the canonical whitelist is + `["dist", "README.md", "CHANGELOG.md"]`. + + The other half is the gate: `check:published-files` gains a fifth invariant, + COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the + always-required lint job, so the next package cannot silently sever the path + again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves + into the canonical set. + + Consumer-visible change: one more file per install (the package's changelog, + e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key" +node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was + promised. + ## 17.0.0-rc.0 ## 16.1.0 diff --git a/packages/vscode-objectstack/package.json b/packages/vscode-objectstack/package.json index 62bece1b95..cdae7a02ac 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": "17.0.0-rc.0", + "version": "17.0.0-rc.1", "publisher": "objectstack", "license": "Apache-2.0", "repository": {