diff --git a/.changeset/pre.json b/.changeset/pre.json index f32e431261..570041a4b6 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -83,22 +83,31 @@ "changesets": [ "action-alias-conflict-warning", "action-body-execution-context", + "action-body-type-gate", "action-body-write-set-lint", "action-crash-vs-rejection", "action-execute-target-precedence", "action-governance-engine-owned", + "action-location-dual-source-c17", + "action-no-placement-lint", "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-strict-envelope-zero", "action-undoable-liveness-corrected", "actions-empty-object-segment-route", "actions-failures-speak-http", "actions-global-key-and-failure-status", + "actor-attribution-seam", "adapter-hono-auth-wildcard-yields", "adr-0044-revise-service-owned-note", "adr-0053-temporal-matrix-skewed-zone", + "adr-0072-nav-target-refs", + "adr-0078-completeness-gate", + "adr-0078-phase3-webhook-triggers", + "adr-0078-phase4-runtime-warns", "adr-0104-advertise-open-gates", "adr-0104-attestation-adr-note", "adr-0104-d1-media-strict-per-deployment", @@ -148,11 +157,20 @@ "adr-0115-plugin-dev-stub-table-verdict", "adr-0116-followups", "adr-0117-owning-business-unit", + "adr-0118-non-user-actor-contract", + "adr-0119-d2-migration-journal", + "adr-0119-plugin-reachable-transactions", + "adr-anchors-authz-sweep", + "adr-anchors-guard", + "adr0104-attest-after-boot-writes", + "agent-code-is-the-record", "agent-knowledge-alias-and-experimental-markers", "agents-md-worktree-staleness", "agents-pd12-alias-retirement-path", "agents-pd12-shim-retired", + "agents-releases-freeze-merge-queue", "agents-spec-generated-artifacts-map", + "aggregate-bulk-dispatch-selected-ids", "aggregate-temporal-output", "aggregation-vocabulary-lockstep", "ai-agent-authoring-and-tools-removal", @@ -160,6 +178,7 @@ "ai-agents-fallback-envelope", "ai-agents-pending-actions-sdk", "ai-namespace-expresses-real-surface", + "ai-route-user-system-permissions", "ai-slot-remedy-tells-the-truth", "ai-surface-affinity-lint", "ai-tool-registry-and-lint", @@ -167,8 +186,10 @@ "analytics-between-predicate-dropped", "analytics-capability-conditional-mounting", "analytics-client-dispatcher-alignment", + "analytics-compare-measure-filters", "analytics-cube-gate-and-error-leak", "analytics-effective-granularity", + "analytics-empty-group-fill-compare-seam", "analytics-execute-aggregate-execution-context", "analytics-filter-operator-coverage", "analytics-label-read-scope", @@ -177,12 +198,14 @@ "analytics-order-by-display-label", "analytics-query-bare-shape-entry-validation", "analytics-read-scope-bridge-order", + "analytics-record-scoping-and-measure-fields", "analytics-timedimension-projection", "analytics-widget-query-options", "api-exposure-failopen-observability", "api-methods-derivation-contract", "apimethod-enum-shrink", "apimethods-batch-conformance-ratchet", + "app-area-fail-open-gates-removed", "app-dead-authoring-keys", "app-metadata-reference-integrity-assessment", "app-navigation-strict", @@ -194,38 +217,57 @@ "approval-attachment-descriptors", "approval-dead-run-ordering-invariant", "approval-dead-run-record-lock", + "approval-decision-survives-restart", "approval-empty-position-admin-override", "approval-lock-schedule-run-provenance", + "approval-override-audit-marker", "approval-participant-visibility", "approval-pending-approver-groups", "approval-reassign-structured-parties", + "approval-record-lock-predicate-updates", "approval-status-mirror-names-the-actor", "approval-vocabularies-derived", "approvals-expose-lock-record", "approvals-payload-labels", + "approvals-stranded-request-inspection", "approver-live-record-3447", "approver-value-sources-and-dead-slot-warning", "array-form-triggertype-not-silent", "attachment-read-visibility-real-filter-semantics", + "attachment-unscoped-multi-delete", + "audit-anchor-and-lookup-integrity", "audit-provenance-and-import-vocabulary", "audit-test-static-imports", "auth-catchall-yields-unowned-paths", + "auth-lazy-cache-rate-limit-store", + "auth-otp-budget-shared-counter-store", + "auth-otp-cooldown-retention-follows-config", "auth-route-ledger", "auth-validationerror-4xx-mapping", + "authorable-surface-delete-proof", "authorable-surface-ratchet", "authoring-key-lint-full-coverage", + "authoring-rule-command-coverage-registry", "authz-ledger-flow-runas", + "auto-org-admin-revoke-delete-signature", "automation-client-resume-screen-flow", "automation-resume-authority-gate", "banner-dsn-connection-display", "batch-create-readonly-ingress", "batch-dropped-fields-observability", + "batch-row-error-codes-registered", + "batch-row-result-schema-shape", "better-auth-1-7-0-rc-2-and-prod-dep-batch", "better-auth-team-member-count", + "blueprint-formula-expression", + "blueprint-summary-operations", "body-write-lint-message-driver-truth", + "book-job-translation-app-authorwarn-keys-retired", "boot-api-merge", + "boot-hydration-scoped-lookup", "builtin-node-config-reconciliation", "bulk-batch-size-cap", + "bulk-data-event-contract", "bulk-writes-bind-to-path-object", "calendar-day-primitive-to-spec", "calendar-day-upper-bound-memory-mongodb", @@ -233,11 +275,15 @@ "calldata-query-fallback-serves-query", "changelog-ships-in-tarball", "chartconfig-trim-zoom-clickaction", + "check-i18n-fails-on-undeclared-authoring-key", + "check-mode-manifest-no-write", "ci-cache-tier1-optimizations", + "ci-merge-queue-throughput", "ci-node-22-pin", "ci-node-eol-guard", "ci-performance-optimization", "ci-rerun-safety-and-timeouts", + "ci-shard-test-core", "ci-test-completeness-guard", "cli-json-pipe-truncation-sweep", "cli-stale-dist-tests-and-project-root", @@ -246,6 +292,10 @@ "client-keys-sharelinks-security", "client-meta-automation-descriptors", "client-packages-lifecycle", + "client-react-bulk-data-hooks", + "client-react-dependency-identity-loops", + "client-react-test-harness", + "client-readme-retired-validate-only", "client-retires-parking-spot-read", "client-url-conformance-capstone", "close-approvals-and-record-shares-gaps", @@ -254,12 +304,15 @@ "close-the-eight-reports-rest-gaps", "close-the-final-nine-rest-gaps", "close-the-nine-metadata-rest-gaps", + "cold-boot-flow-bind-read-decorations", "connector-authoring-guide", "connector-descriptors-meet-their-contract", + "connector-template-cluster-removed", "console-09c6a177bb4a", "console-1bb77aa24514", "console-2cb8d78e24ad", "console-4a4829d0ef39", + "console-785b8a5d432c", "console-7d9734d5e321", "console-96ee72e85439", "console-a136322f8723", @@ -270,23 +323,44 @@ "console-pin-pr-gate", "control-flow-form-zod-ledger", "control-plane-guard-crossref", + "converge-activation-event-schema", "conversion-notice-channel", "criteria-json-declaratively-required", "cross-object-batch-501-code", + "cross-repo-issue-closer", "current-user-endpoints-kernel-resolver", "d12-fake-inventory-gate", + "dashboard-strict", + "data-driver-find-stream-retired", + "data-event-contract", + "data-field-changed-event-removed", "data-path-object-existence-gate", "data-query-path-object", + "database-loader-ddl-failure-loud", + "dataset-percent-scale-chain", "datasource-admin-503-names-its-own-service", "datasource-availability-observability", "datasource-bound-connect-failfast", + "datasource-capabilities-retired", + "datasource-config-driver-contract", + "datasource-config-key-alias-conversion", + "datasource-external-inert-keys-retired", + "datasource-health-check-retired", + "datasource-mapping-is-routing", + "datasource-read-replicas-removed", + "datasource-retry-policy-retired", "datasource-routes-catch-service-throws", "datasource-teardown-ownership", + "datasync-conflict-dual-source-c13-c15", "date-bucket-parity-gate", "date-now-default-utc", "datetime-canonical-utc-storage", "datetime-storage-form-memory-mongodb", + "ddl-runtime-token-default", + "decision-branch-routing-enforced", + "decision-output-required-pin", "decision-outputs-surface-3447", + "declarative-cron-job-schedule-envelope", "declared-unique-index-not-legacy", "default-datasource-adopt-seam", "default-datasource-declared", @@ -300,6 +374,7 @@ "dev-plugin-production-hatch-brands", "dev-plugin-protocol-family-removed", "dev-plugin-security-stubs-and-prod-guard", + "discovery-cache-queue-job-no-route", "discovery-data-slot-computed", "discovery-metadata-slot-computed", "discovery-remedy-names-a-real-package", @@ -309,12 +384,14 @@ "dispatcher-handler-ready-gate", "dispatcher-returned-error-leak", "dispatcher-validation-error-fields", + "doc-tags-connects-book-tag-include", "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-extend-faq-reversed-rot", "docs-fieldschema-extend-rot", "docs-plugin-spec-onupgrade-example", "docs-v17-plugin-retirement-notes", @@ -322,7 +399,9 @@ "dogfood-gate-cancelled-not-failure", "dogfood-shared-boot", "domain-error-passthrough", + "driver-capabilities-inert-bits-removed", "driver-conformance-gate", + "driver-conformance-zero-discovery", "driver-connect-bound-and-reconnect-correction", "driver-options-bypass-tenant-audit", "driver-sql-logicalop-retention-note", @@ -333,6 +412,15 @@ "drop-require-auth", "drop-undeclared-actions-valve", "dropped-fields-bulk-graphql-client", + "dual-source-contracts-convergence", + "dual-source-cross-form-convergence", + "dual-source-export-ratchet", + "duplicate-fix-guard", + "duplicate-package-flow-canonicalization", + "durability-degradation-log-level", + "durable-suspended-screen-refetch", + "eighty-donkeys-repeat", + "email-template-materializer-bridge", "empty-capability-answers-501", "empty-group-bucket-key-null", "empty-state-gate-object-surface", @@ -344,6 +432,8 @@ "engine-rejects-wire-only-aliases", "engines-node-22", "envelope-violations-predicate", + "environment-artifact-dual-source-c10", + "event-schema-dual-source-c6", "export-axis-opt-in", "export-empty-result-header", "export-honors-search-term", @@ -354,27 +444,38 @@ "fault-edge-label-lint", "field-conditional-required-fold", "field-file-collection-dogfood-proof", + "field-mapping-tri-source-c12", "field-readonly-doc-preserveaudit", + "field-strict-guidance", "field-time-canonical-storage", "file-access-delegate", + "filter-app-areas-nav-gate", "filter-context-tokens-gate", + "filter-logic-conformance-mongodb-wasm", "filter-logic-conformance-single-source", "filter-no-silent-drop", "filter-tokens-runtime-resolver", "find-data-wire-context", + "findone-requires-a-predicate", + "fix-cross-repo-closer-octokit", + "fix-cross-repo-closer-require", "fix-stale-hono-changeset-ref", "fix-stale-scaffolder-changeset-refs", "fix-unmounted-local-file-url", "flow-action-record-id-seeding", + "flow-branch-gates-and-inert-condition", + "flow-condition-bare-string-is-cel", "flow-create-record-write-lint", "flow-error-object-serialization", "flow-executors-parse-config", "flow-filter-collapse-and-write-path-tokens", + "flow-function-declared-effect", "flow-lookup-expand", "flow-max-retries-single-default", "flow-nested-region-walk", "flow-node-config-alias-graduation", "flow-node-expression-ledger", + "flow-node-type-audit-at-boot-close", "flow-node-write-set-lint", "flow-run-summaries", "flow-system-run-audit-attribution", @@ -382,15 +483,19 @@ "flow-template-lint-and-hydrate-guards", "flow-template-paths-into-reference-integrity-suite", "flow-trigger-unknown-event-lint", + "form-layout-lint-wired", "form-section-pane", "formview-buttons-defaults-live", + "govern-remaining-nine-metadata-types", "govern-report-dashboard-liveness", "govern-sys-member-writes", "govern-webhook-liveness", "group-key-read-shape", "group-union-driver-scope", "guard-refusal-chokepoint", + "has-is-not-a-null-guard-lint", "historical-import-audit-docs", + "homepageid-tombstone-premise-corrected", "honest-service-self-description", "hono-current-user-endpoints-exported", "hono-current-user-endpoints-ungated", @@ -398,15 +503,25 @@ "hono-standalone-discovery-computed", "hono-standard-endpoints-default-off", "hook-bodies-write-set-docs", + "hook-body-crypto-hash-retired", "hook-body-write-set-lint", + "hook-condition-fail-loud", + "hook-condition-merged-record", + "hook-condition-previous-binding", "hook-empty-target-not-wildcard", "hook-vs-flow-path-guidance", + "host-app-resolver-shared", "http-contract-unification", + "http-method-dual-source-c14", + "http-protocol-discovery-two-shapes", + "http-request-dual-source-c11", "http-server-exemption-revoked", + "hungry-donkeys-repeat", "i18n-bundle-drift-sweep", "i18n-consolidate-success-builder", "i18n-coverage-ratchet", "i18n-extract-check-flag", + "i18n-extract-configs-drop-undeclared-name", "i18n-field-labels-emit-declared-shape", "i18n-field-labels-shared-nested-derivation", "i18n-gate-declared-labels", @@ -423,23 +538,28 @@ "import-undo-preserveaudit", "index-drift-migrate-plan", "inert-rule-warn-dedupe", + "init-service-declared-enforced", "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", + "job-runtime-create-closed", + "kernel-metadata-loader-envelope-removed", "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-searchable-fields-type-validation", "lint-system-fields-derived", "lint-translation-reference-integrity", "lint-unique-double-declaration", "list-column-prefix-summary-object", "liveness-evidence-path-resolution", + "liveness-governs-every-registered-type", "liveness-ledger-ai-scope-honesty", "liveness-orphan-row-gate", "liveness-register-orphan-proofs", @@ -447,6 +567,9 @@ "liveness-verified-at-clock", "localized-field-validation-messages", "manifest-bridge-arm-on-project-kernels", + "many-data-atomic-real-or-refused", + "mapping-agent-page-strict", + "mapping-context-selector-unwarnable-keys-retired", "marketplace-objects-bridge-metadata-service", "marketplace-rehydrate-seed-heal", "marketplace-seed-test-budget", @@ -456,22 +579,37 @@ "memory-datasource-ephemeral-per-pool", "memory-driver-opt-in-persistence", "messaging-declares-its-event-object", + "messaging-outbox-no-updated-at-on-update", + "meta-canonical-type-segment", + "meta-migrate-stored-route", + "meta-overlay-write-through-dispatch", "meta-type-gate-plural", + "metadata-event-contract", + "metadata-event-dual-source-kernel-side", "metadata-form-zod-reconciliation", "metadata-remove-artifact-api-source", "metadata-type-registration-names-a-real-hook", "metadata-unresolvable-posture-fail-closed", + "metadata-watch-event-canonical-enum", + "metadataformat-cachestrategy-single-declaration", + "migrate-meta-stored-rewrite", "migrate-occupancy-and-deferred-ddl", "migrate-occupancy-file-descriptor-signal", "migrate-plan-lists-datetime-convergence", "migrate-search-companion-parity", + "migration-journal-boot-recovery", "modal-actions-are-client-only", "mongodb-single-tenant-boot-guard", "naming-drift-recheck", "nav-access-lint", "nav-item-input-type", + "next-event-seq-read-failure-loud", + "notification-dual-source-c3", + "notification-orphan-template-schemas", "notifications-redos-fix", "notify-source-shape-conversion", + "object-enable-trash-mru-migration-surface", + "object-parse-path-strict", "objectchart-aggregate-result-columns", "objectchart-contract-back-to-spec-shape", "objectql-crossobj-capability", @@ -480,12 +618,17 @@ "objectql-driver-connect-failfast", "objectql-engine-contract", "objectql-strategy-daterange", + "objectui-pin-changeset-from-declarations", + "orderby-direction-vocabulary", "osv-batch-2026-07-dep-bumps", + "package-dependency-dual-source-c7", "packages-envelope-suite-comment", "page-field-and-chart-binding-lint", "page-header-i18n-3589", "paged-read-determinism", + "pagination-filter-logic-driver-axis", "per-package-typecheck-coverage", + "permission-backfill-row-state-columns", "pin-control-flow-designer-forms", "platform-always-on-capabilities", "platform-objects-app-i18n-phantom-debt", @@ -493,10 +636,17 @@ "plugin-ordering-declared-contract", "plugin-ordering-provider-declarations", "plugin-page-i18n-drift-guard", + "pm-dispatch-agent-tooling", + "pm-dispatch-domain-lanes", + "pm-dispatch-escalation-bar", + "pm-dispatch-round1-lessons", + "pm-dispatch-triage-lessons", + "pm-dispatch-triage-tooling", "preserveaudit-test-and-docs", "preview-omits-virtual-fields", "previous-null-on-create-leg", "prose-example-gate-covers-docs", + "protection-envelope-invariant-was-hollow", "prune-aspirational-config-3494", "prune-dead-audit-config-cluster", "prune-dead-capabilities-descriptor", @@ -508,16 +658,20 @@ "prune-skill-permissions", "public-book-grant", "published-files-whitelist", + "published-pm-dispatch-skill", "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", + "rate-limit-config-dual-source-c9", "rbac-objects-bulk-primitive", "react-block-field-props-lint", + "react-blocks-declaration-parity-not-conformance", "react-listview-searchable-fields-lint", "react-page-props-joins-the-suite", + "react-tier-record-blocks-withdrawn", "readme-fde-audience", "readonly-flow-write-json-warning", "readonly-flow-write-lint", @@ -527,8 +681,10 @@ "reconcile-packages-post-and-ui-view-dialect", "record-after-write-trigger", "record-change-hydrate-formula-fields", + "recorded-by-nullable-lookup", "recursive-schema-input-pins", "recursive-schema-input-types", + "reference-id-embedded-record", "reference-integrity-object-and-action-names", "reference-integrity-wiring-guard", "regenerate-ui-action-reference-doc", @@ -536,6 +692,8 @@ "region-slots-single-declaration", "region-validator-coverage", "register-field-file-collection-proof", + "registered-types-batch-four", + "registry-gate-wiring-ratchet", "reject-body-on-non-script-action", "reject-malformed-filter-array", "release-hotcrm-gate-premode", @@ -559,9 +717,12 @@ "rest-list-unknown-query-params", "rest-patch-data-dropped-fields", "rest-route-ledger-audit-guard", + "rest-server-openapi31-block-removed", "resume-gate-map-chain-and-reserved-vars", "resume-signal-chokepoint", + "retire-activation-events", "retire-batch-validate-only", + "retire-data-engine-batch", "retire-default-dispatcher-routes", "retire-degraded-analytics-shim", "retire-dev-analytics-stub", @@ -569,11 +730,14 @@ "retire-dispatcher-storage-bridge", "retire-generated-paths-filter", "retire-inert-driver-plugin-options", + "retire-managed-by-system-bucket", + "retire-runtime-capabilities-doc-page", "retire-the-dev-stub-table", "retire-the-dispatcher-auth-mock", "retire-three-deprecated-aliases", "retire-three-orphan-operator-vocabularies", "retirement-prescriptions-name-protocol-17", + "retry-policy-dual-source-c8", "rls-enabled-enforced-security-audit", "rls-priority-removed", "route-audit-tranche-3-service-mounts", @@ -586,30 +750,40 @@ "runtime-action-execution-module", "runtime-actions-mcp-extraction", "runtime-auth-ai-extraction", + "runtime-authoring-gate", "runtime-automation-extraction", "runtime-domain-body-extraction", "runtime-domain-extraction-batch3", "runtime-domain-handler-registry", "runtime-meta-data-extraction", + "runtime-overlay-not-artifact", "runtime-packages-extraction", "runtime-share-links-extraction", "sandbox-structured-error-passthrough", + "save-flow-canonicalization-fallback-warning", + "save-meta-item-flow-canonicalization", "savemeta-persists-normalized-operators", "scaffolds-drop-memory-driver", + "schema-name-suffix-strip", "schemaless-node-config-contracts", + "schemaless-node-expression-ledger", "scim-provider-key-and-sso-scim-parity", "scoped-invitation-placement", "screen-field-visible-when-on-the-wire", + "screen-resume-declared-field-contract", + "script-branch-keys-retired", "searchable-fields-stale-declaration", "security-get-readable-fields", "security-props-liveness-recheck", "security-service-contract", "seed-datasets-multitenant-replay-union", + "seed-env-enforced", "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-replay-tenant-stamp", "seed-state-machine-lint", "seed-summary-banner", "seed-summary-marketplace", @@ -619,9 +793,12 @@ "serve-fallback-declared-default", "serve-named-artifact-and-ordering-truth", "serve-no-artifact-definestack-pin", + "serve-organizations-host-resolution", + "serve-organizations-mount-vs-import", "service-error-envelope-conformance", "service-lookup-any-guard", "service-storage-success-envelope", + "session-dual-source-c4", "settings-error-details-declared-slot", "share-link-routes-envelope", "share-links-dispatcher-dual-key", @@ -630,6 +807,7 @@ "sharing-rule-criteria-required", "sharing-rule-recipient-reconcile", "sharing-rule-unknown-sort-and-stale-help", + "sharing-rule-withdrawal-and-delete", "showcase-action-disabled-specimen", "showcase-approver-and-picker-specimens", "showcase-bulk-actions-example", @@ -644,9 +822,12 @@ "slot-lookup-type-argument-ratchet", "slot-lookups-return-their-contract", "sort-dotted-path-rejected", + "spec-api-surface-baseline-percent-scale", "spec-changes-manifest-catchup", "spec-check-generated-aggregate", + "spec-generated-merge-driver", "spec-property-retirement-skill", + "spec-vitest-testtimeout", "sql-driver-dialect-connect-timeout", "sqlite-datetime-date-bucket", "sqlite-wal-journal-mode", @@ -656,18 +837,27 @@ "standard-endpoints-parity-correction", "standard-endpoints-precedence-pin", "startup-log-noise-cleanup", + "startup-registry-verdict-guard", "step2-metadata-protocol-plugin", "step2-prc-single-source", "storage-adapter-swap-verdict", "storage-download-filename", "stored-metadata-replays-the-chain", + "stored-migration-covers-flows", + "strict-object-registered-types", "strictness-ledger-gate", "strictness-ledger-recursive-coverage", "strip-read-decorations-on-save", + "studio-strict", + "summary-index-registry-revision", "sweep-close-out", + "sys-comment-record-level-authorization", + "sys-comment-retire-visibility-reply-count", "sys-migration-ledger-platform-infra", "sys-secret-store-platform-infra", "sys-view-definition-default-open", + "system-field-name-injected-columns", + "temporal-conformance-driver-axis", "temporal-conformance-matrix", "temporal-conformance-stall-guard", "temporal-conformance-token-axis", @@ -676,17 +866,24 @@ "temporal-hooks-on-contract", "temporal-storage-form-axis-tests", "temporal-time-axis", + "tenancy-default-org-fail-closed", + "tenant-plan-dual-source-c16", + "tender-donkeys-smoke", "tender-hats-brush", "test-core-stall-guard", "tests-off-memory-driver", + "tidy-eyes-shine", "tombstone-agent-tools", "tool-inert-keys-removed", "tool-requires-confirmation-not-enforced", "tool-requires-confirmation-removed", + "translation-groups-strict", + "trigger-registry-connector-cluster-removed", "two-factor-lockout-and-object-translations", "two-factor-lockout-extension", "two-factor-lockout-follows-settings", "type-blind-temporal-date-operands", + "type-bulk-action-defs", "typecheck-covers-the-test-layer", "typed-decision-outputs-3447", "ui-discovery-reads-the-protocol-service", @@ -702,25 +899,38 @@ "unordered-paged-read-determinism", "update-record-dropped-field-warnings", "url-field-accepts-relative-urls", + "user-field-implicit-target", "user-less-run-data-ops-refused", "user-level-export-axis", "v17-dissolve-protocol-alias", + "v17-docs-sweep-run-4", + "v17-metadata-options-pointer", "v17-page-console-gap-and-nav", "v17-page-rc1-window", "v17-rc-anchor", "v17-release-page", + "v17-rest-envelope-defects", + "v17-verification-defects-docs", + "v17-watch-event-raw-values-note", "vacuous-filter-carveouts", "validate-runs-build-authoring-lints", + "validation-kind-retired", + "validation-variants-strict", "variant-doc-drift-gate", "variant-docs-exemption-audit", + "verify-harness-durable-suspended-runs", "verify-multitenant-requests-isolated-posture", "view-ast-operator-parity", + "view-strict-final", "wait-loose-config-graduation", "wait-timeout-keys-retired", + "wasm-sqlite-returning-writes-persist", "webhook-authoring-surface-bridge", + "webhook-dual-source-api-side", "webhook-liveness-ledger-flip", "webhooks-drop-dead-delivery-i18n", "wildcard-fallthrough-guard", - "withdraw-adr-0107-drop-writes-proposal" + "withdraw-adr-0107-drop-writes-proposal", + "workflow-slot-retired" ] } diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index 380a9f1f3c..795ab9924f 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,271 @@ # @objectstack/example-crm +## 4.0.92-rc.2 + +### Patch Changes + +- e533b0b: feat(spec)!: retire `datasource.capabilities` — eleven flags nothing read, one of them a safety claim (#4583) + + `DatasourceCapabilities` declared eleven booleans — `transactions`, seven `query*` + flags, `joins`, `fullTextSearch`, `readOnly`, `dynamicSchema` — all strict-guarded, + all read by nothing. Pushdown is decided by the runtime driver's own `supports.*` + object, a different mechanism entirely, so a datasource declaring + `queryAggregations: false` never once changed which engine path ran. The block is + removed rather than bridged: there was nothing on the other side to connect it to. + + **`readOnly` is why this is not tidy-up.** It reads as a safety property and was + authored as one — the shipped CRM example labelled a datasource "CRM Analytics Read + Replica" on the strength of it, while the datasource accepted writes exactly like the + primary. The key had already been MOVED twice toward somewhere it might be enforced, + out of `config` in #4410 and into `capabilities` in #4465, and was inert at every + address. This removes it instead of moving it a third time. + + **Removing it does not hand you a working replacement, and the rejection says so.** + The one enforced datasource-wide write gate is `external.allowWrites: false`, and it + applies only to a FEDERATED datasource — `assertWriteAllowed` returns early for a + `managed` (or unset-`schemaMode`) datasource, so that key would be equally inert for a + local database. **A managed datasource has no read-only gate at all**; that gap is + #4584, deliberately not invented here. Until it is answered, enforce read-only where + it is real: grant the connection SELECT-only at the database. + + FROM → TO: + + ```ts + // before — parsed cleanly, changed nothing + defineDatasource({ + name: 'analytics', driver: 'sqlite', config: { filename: ':memory:' }, + capabilities: { readOnly: true, queryAggregations: true }, + }) + + // after — delete the block; for a FEDERATED datasource the enforced gate is: + defineDatasource({ + name: 'warehouse', driver: 'postgres', config: { … }, + schemaMode: 'external', + external: { allowWrites: false }, + }) + ``` + + `os migrate meta --from 16` rewrites it automatically (ADR-0087 conversion + `datasource-capabilities-removed`). Both `DatasourceSchema` and + `DriverDefinitionSchema` are `.strict()`, so a leftover key is a loud rejection + carrying the prescription — never a silent strip. + + Also fixed: `READ_ONLY_BELONGS_ON_DATASOURCE`, the prescription every SQL driver + shares for a `readOnly` written inside `config`, was still sending authors _to_ the + removed key. It now names the enforced gate and states plainly where that gate does + not apply — a prescription that lands on an inert key manufactures exactly the belief + it was meant to correct. + + The `datasource` liveness ledger drops from 20 dead properties to 9 (remaining: + `healthCheck` ×3, `retryPolicy` ×4, `external` ×2 — batches B/C/D of #4583). + +- 5293114: fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) + + A `decision` node advertised three mechanisms for splitting a path and only one + of them did anything. The other two were the ADR-0049 `declared ≠ enforced` + shape, and the pair of them shipped a guard that does not guard in + `examples/app-crm`. + + | mechanism | before | now | + | :--------------------------------------------------- | :----------------------------------------------------------------------------------------------------- | :---------------------------------------------------- | + | `edge.condition` | ✅ the only one that worked | unchanged | + | `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | + | `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | + + ## What was broken, end to end + + `crm_convert_lead_wizard` means "already converted → abort screen; otherwise → + the wizard". It ran **both**: an already-converted lead got + "This lead has already been converted" and then walked straight into the + conversion wizard behind it. Four independent silences stacked up: + + 1. the decision's first condition was authored `{lead_record.status} == +'converted'` — braces in a slot declared bare CEL, so it was string-compared + and never true; + 2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; + 3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal + discarded the branch and considered every out-edge; + 4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking + it `isDefault: true`, was a dead key. + + ## The model + + `branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` + catches whatever is left. Concretely: + + - **`isDefault` is enforced.** A default edge is traversed only when no + conditional sibling of the same source node matched, and it is no longer part + of the unconditional parallel fan-out — that distinction is the whole point of + the marker. Passed over because a real branch won, its target records the same + `skipped` step a closed gate does (#4354). + - **An unclaimable branch label warns.** Traversal still falls back to the full + edge set (a run mid-flight must not die on a metadata error) but says so, + naming the computed branch and the out-edge labels that exist. + - **A decision that declares no `conditions` reports no branch.** It used to + report `'default'` unconditionally — a label no out-edge in the repo ever + carried — which is why every decision node fell back to the full edge set. + The `'default'` sentinel survives for the case it actually describes (declared + conditions, none matched) and is now claimed by the `isDefault` edge as well + as by an edge literally labelled `'default'`. + - **`conditions[].expression` is evaluated as the bare CEL it is declared to + be.** The raw string went to the legacy `{var}` template path, where + `lead.status == 'converted'` cannot resolve and the branch is decided by + string comparison. Unlike `edge.condition` this slot carries no + `ExpressionInput` envelope — the decision descriptor is deliberately + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate + now fails loudly (ADR-0032 §1c) instead of deciding `false`. + + ## Caught at authoring time too + + Four new `os build` / `os validate` warnings, because a wrong route is silent at + run time by nature (Prime Directive #12): + + `flow-branch-label-unmatched` (the shipped shape), + `flow-decision-unconditional-branch` (a guarded decision with an unconditional + sibling — the actual hole), `flow-default-edge-with-condition` and + `flow-multiple-default-edges`. + + Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent + after it. + + ## Effect on flows that already exist + + Enforcing `isDefault` changes how a **stored** flow behaves, and the flows it + changes are mostly Studio's own. `objectui`'s flow edge inspector has always + written `isDefault: true` when you bind an out-edge to a decision's default/else + branch — into a key with zero readers, so that edge ran unconditionally, in + parallel with whichever branch actually matched. Those flows now take exactly + one branch. That is the fix, but it is a behaviour change on existing data + rather than only on newly authored metadata, so it is worth knowing before + upgrading: a flow that quietly ran two paths will now run one. + + Nothing changes for an edge that never carried the marker — `isDefault` defaults + to `false`, and an ordinary unconditional out-edge still fans out in parallel + exactly as before. + + ## The example app + + `crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the + redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One + mechanism per decision, and exactly one branch runs. + + Verified: 11 new engine/executor tests (including the reported repro in both + directions), 12 new linter tests; `@objectstack/service-automation` 577 tests + and `@objectstack/cli` 652 tests green, all three example apps build with no new + findings. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + ## 4.0.92-rc.1 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index ced777f267..6ab24c3626 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.1", + "version": "4.0.92-rc.2", "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 876ff5e0af..36366aef91 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,208 @@ # @objectstack/example-showcase +## 0.3.14-rc.2 + +### Patch Changes + +- c13350b: feat(spec)!: retire `external.label` and `external.requirePermission` (#4583 batch D) + + Two keys on the federation block, both read by nothing. + + **`external.label`** — nothing rendered the federation block's own label. Setup → + Datasources renders the datasource's **top-level** `label`, which every datasource already + has, so this was a second display name that never displayed. The showcase example declared + both; it now declares only the one that shows. + + **`external.requirePermission`** — no authorization check ever consulted it. A permission + named here gated nothing: access to a federated datasource's data is governed by the + ordinary object permission sets and RLS, exactly as for a managed datasource. Naming a + permission that is never required is the false-compliance shape ADR-0049 exists to remove + — it reads like an access control and is one only in the author's head. + + FROM → TO: delete `external.label` (use the top-level `label`); delete + `external.requirePermission` and grant or withhold the object permissions instead. + `os migrate meta --from 16` removes both automatically (conversion + `datasource-inert-blocks-removed`). + + With these, the `datasource` liveness ledger reaches **zero dead properties** — down from + the 20 it was seeded with in #4487, the highest dead ratio of any governed type. + +- d449b0c: fix(cli): gate the two decision-routing shapes that can never work, and flag the inert `config.condition` (#4414) + + Two follow-ups to #4440, both about metadata that reads like a guard and is not + one. + + ## Two rules promoted to `error` + + `flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the + build instead of warning. The bar for that — restated at the top of + `lint-flow-patterns.ts`, because the old one no longer described the set — is + **no reading of the author's metadata does what it says, deterministically, on + every run**. Both qualify: a branch label no out-edge carries cannot route, and + an edge that is both `isDefault` and conditional always lets the condition win, + so the marker routes nothing. Neither _fails_; both are wrong every time and + silently, which is worse. + + The other two stay advisory on purpose, and the policy now says why: + `flow-decision-unconditional-branch` is usually a guard that does not guard, but + one guarded plus one unconditional out-edge is also a legal "maybe notify, + always continue" fan-out, and `flow-multiple-default-edges` can genuinely mean + "when nothing matched, do both". The bar is about _provability_, not severity of + consequence — failing a customer's build on a shape we cannot prove wrong is the + worse trade. + + No wiring change was needed: `lintFlowPatterns` is already registered as + `tier: 'gating'` across all three commands (#4409), which is exactly the seam + `authoring-rule-wiring.test.ts` exists to guard. + + ## New rule: `flow-inert-node-condition` + + `config.condition` is the trigger gate on a `start` node and is read by **no + other node type** — the engine parse-validates it everywhere (so a malformed one + is caught) and then ignores it. On a `decision` the name makes it read as the + branch predicate, which is exactly how it got authored. + + Three of the three bundled apps had one. `app-todo`'s `check_recurring` and + `app-showcase`'s `needs_exec` both carried a predicate their out-edges were + already enforcing — a third copy doing nothing. The showcase even had a comment + next to it saying the node condition "is not evaluated by the engine", and kept + it anyway; that is the residue this rule exists to stop accumulating. Both are + now plain exclusive gateways. + + Advisory, not gating: the surrounding edges usually still route correctly, so + this is dead weight rather than a provable misroute. The node-type list is a + closed set of builtins we have actually read, not "everything that isn't + `start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately + declare and read its own `config.condition`. + + ## Studio + + `objectstack-ai/objectui` carries the matching help-text fixes: the branch editor + said a `true` branch **is** the default/else path (it is how you _ask_ for one — + the marker goes on the out-edge), and the legacy single `Condition` field said + "prefer Branches above", which reads as "this works, but the other is better". + It does not work at all. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/driver-sql@17.0.0-rc.2 + - @objectstack/service-datasource@17.0.0-rc.2 + - @objectstack/cloud-connection@17.0.0-rc.2 + - @objectstack/connector-mcp@17.0.0-rc.2 + - @objectstack/connector-openapi@17.0.0-rc.2 + - @objectstack/connector-rest@17.0.0-rc.2 + - @objectstack/connector-slack@17.0.0-rc.2 + ## 0.3.14-rc.1 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 03c78da0a5..8c152dfa6e 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.1", + "version": "0.3.14-rc.2", "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 d73a0c11e3..9ae2464558 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,271 @@ # @objectstack/example-todo +## 4.0.92-rc.2 + +### Patch Changes + +- 7d21581: feat(spec)!: retire the six remaining `authorWarn` dead keys — book/group `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` (#4667) + + The #4488 liveness audit marked as `authorWarn` the keys whose _declaration_ + actively misleads — not merely unread, but shaped so an author reasonably + concludes they configure something. #4509 and #4583 cleared the rest; these six + are what remained, and each shipped with its own reason for reading alive. + + **The retirement kit:** + + | FROM | TO | Fix | + | -------------------------------- | ----------- | -------------------------------------------------------------------------------------------- | + | `book.translations` | _(removed)_ | Delete the key. Localize the **docs** — `doc.translations` is live on every doc render path. | + | `book.groups[].translations` | _(removed)_ | Same. Tombstoned, since `BookGroupSchema` is not `.strict()`. | + | `job.id` | _(removed)_ | Delete the key. `name` is the job's identity everywhere. | + | `translation.validationMessages` | _(removed)_ | Delete the key. Author the message on the rule: `object.validations[].message`. | + | `app.homePageId` | _(removed)_ | Delete the key. Reorder `navigation`; set `isDefault` for the root landing. | + | `app.areas[].order` | _(removed)_ | Delete the key. Reorder the `areas` array itself. | + + Run `os migrate meta --from 16` to rewrite existing sources automatically. + + **Each read alive for a different reason, and the prescriptions say which:** + + - **book `translations`** — _proximity_. `doc.translations`, two files over, same + name and shape, works on every read path. The book-level map was parsed, + stored and round-tripped, and rendered in the authoring locale to every + reader: the tree endpoint and the portal emit `label` / `description` + verbatim. + - **`job.id`** — _its own description_. "Defaults to `name` when omitted" + advertises an identity override that does not exist. `name` is the scheduling + key, the `sys_job` row key, and the `JobExecution.jobId` stamp — so two jobs + differing only in `id` were one job declared twice. + - **`translation.validationMessages`** — _the platform's own signposts, twice_. + The schema example showed a concrete override, and #3778's legacy-key + migration table steered retired `errors:` authors straight into it. **That + guidance entry is rewritten here**: retiring one dead key by pointing at + another is the defect, not the fix. + - **`app.homePageId`** — _a second source for one fact_. Not unread: objectui's + console consumed it in `resolveLandingRoute()` and it was the only thing + deciding where an app opened. (This entry first shipped saying otherwise; + corrected in #4709, which upheld the removal.) What condemns the key is its + shape — an ID cross-reference into `navigation` with no referential integrity, + falling back to the first item _silently_ when the id dangled. If "land + somewhere other than first" is ever wanted again it belongs on the navigation + item itself, not on a pointer that can miss. + - **`app.areas[].order`** — _the sibling that works_. Nav-item `order` really is + sorted; area-level order never was, and both renderers iterate the array as + authored. + + **Routes differ, deliberately.** `book.groups[].translations` and + `app.homePageId` are **tombstoned** (`retiredKey`: `never` at compile time, a + prescription at parse time) — the group schema is a plain `z.object`, where a + bare delete would have zod silently strip the key, trading one silent no-op for + another. The other four are strict deletions carrying `guidance`. Retired alias + spellings (`i18n`, `home`, `homepage`, `landingpage`, `sort`) route to the same + prescriptions rather than renaming onto keys that are gone. + + Registered as three ADR-0087 D2 conversions (`book-translations-removed`, + `job-id-removed`, `translation-validation-messages-removed`) plus an extension + of `app-dead-authoring-keys-removed`, all wired into the protocol-17 D3 chain. + + **Also corrected, both found by the gates rather than by grep:** the published + `objectstack-i18n` skill taught `validationMessages` in a copy-paste example + (an AI reproduces that verbatim), and `examples/app-todo` authored the group in + three locales — where the `en` entries merely duplicated the rule's own text and + the zh-CN / ja-JP translations had never once been rendered. + + After this, the only `authorWarn` keys left in the ledger are the two fail-open + area gates tracked in #4651, which need a decision rather than a patch. + +- d449b0c: fix(cli): gate the two decision-routing shapes that can never work, and flag the inert `config.condition` (#4414) + + Two follow-ups to #4440, both about metadata that reads like a guard and is not + one. + + ## Two rules promoted to `error` + + `flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the + build instead of warning. The bar for that — restated at the top of + `lint-flow-patterns.ts`, because the old one no longer described the set — is + **no reading of the author's metadata does what it says, deterministically, on + every run**. Both qualify: a branch label no out-edge carries cannot route, and + an edge that is both `isDefault` and conditional always lets the condition win, + so the marker routes nothing. Neither _fails_; both are wrong every time and + silently, which is worse. + + The other two stay advisory on purpose, and the policy now says why: + `flow-decision-unconditional-branch` is usually a guard that does not guard, but + one guarded plus one unconditional out-edge is also a legal "maybe notify, + always continue" fan-out, and `flow-multiple-default-edges` can genuinely mean + "when nothing matched, do both". The bar is about _provability_, not severity of + consequence — failing a customer's build on a shape we cannot prove wrong is the + worse trade. + + No wiring change was needed: `lintFlowPatterns` is already registered as + `tier: 'gating'` across all three commands (#4409), which is exactly the seam + `authoring-rule-wiring.test.ts` exists to guard. + + ## New rule: `flow-inert-node-condition` + + `config.condition` is the trigger gate on a `start` node and is read by **no + other node type** — the engine parse-validates it everywhere (so a malformed one + is caught) and then ignores it. On a `decision` the name makes it read as the + branch predicate, which is exactly how it got authored. + + Three of the three bundled apps had one. `app-todo`'s `check_recurring` and + `app-showcase`'s `needs_exec` both carried a predicate their out-edges were + already enforcing — a third copy doing nothing. The showcase even had a comment + next to it saying the node condition "is not evaluated by the engine", and kept + it anyway; that is the residue this rule exists to stop accumulating. Both are + now plain exclusive gateways. + + Advisory, not gating: the surrounding edges usually still route correctly, so + this is dead weight rather than a provable misroute. The node-type list is a + closed set of builtins we have actually read, not "everything that isn't + `start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately + declare and read its own `config.condition`. + + ## Studio + + `objectstack-ai/objectui` carries the matching help-text fixes: the branch editor + said a `true` branch **is** the default/else path (it is how you _ask_ for one — + the marker goes on the out-edge), and the legacy single `Condition` field said + "prefer Branches above", which reads as "this works, but the other is better". + It does not work at all. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [84b4a3a] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [c4ab50b] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [9b43ee2] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [beefe89] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [24915d2] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/client@17.0.0-rc.2 + - @objectstack/service-knowledge@17.0.0-rc.2 + - @objectstack/metadata@17.0.0-rc.2 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.2 + - @objectstack/mcp@17.0.0-rc.2 + - @objectstack/knowledge-memory@17.0.0-rc.2 + ## 4.0.92-rc.1 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index ec2bd80645..7caa51c353 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.1", + "version": "4.0.92-rc.2", "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 13e05656e1..90259781dc 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,130 @@ # @objectstack/example-embed-objectql +## 0.0.32-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/driver-memory@17.0.0-rc.2 + ## 0.0.32-rc.1 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 2be7bf40e1..b8a8dafb44 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.1", + "version": "0.0.32-rc.2", "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 1a07281c48..c02940da9e 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,29 @@ # @objectstack/hono +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [7e7a605] +- Updated dependencies [2826d1e] +- Updated dependencies [63b33e6] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [ac471a0] +- Updated dependencies [eb4204b] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [8aacf94] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [5a84d41] +- Updated dependencies [ea90179] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/plugin-hono-server@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 6d91148f91..269c1f56b0 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.1", + "version": "17.0.0-rc.2", "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 7791582fa8..33a24a9395 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/account +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index b08a6a5fd6..b51e0a531f 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.1", + "version": "17.0.0-rc.2", "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 8673baf977..52657bf2be 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/setup +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index a16956a005..0a9f6656f4 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.1", + "version": "17.0.0-rc.2", "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 f1659df075..131763809e 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/studio +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index ae0005c517..db964ab55f 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.1", + "version": "17.0.0-rc.2", "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 5d8fb927cc..560facb782 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1182 @@ # @objectstack/cli +## 17.0.0-rc.2 + +### Major Changes + +- 0e96e46: refactor(spec,cli,runtime)!: 退役 `crypto.hash` 能力 —— 声明了四层、构建期还自动推断,沙箱从没实现(#4391,ADR-0049 enforce-or-remove) + + `crypto.hash` 是四层声明、零层实现:`HookBodyCapability` 枚举收它、枚举旁的文档表列它、CLI 提取器**自动推断**它、`ScriptContext.crypto.hash` 还写了签名 —— 而 `installCtx` 只往 VM 的 `ctx.crypto` 上装了 `randomUUID`。于是这个 token 唯一授权的那次调用,**每一次都在 VM 里抛**。 + + 这比普通的 declared ≠ enforced 更毒一档,坏就坏在**构建期推断**:作者(尤其是 AI 作者)写下 `ctx.crypto.hash(...)`,提取器就替他把能力加进 `capabilities`,`os build` 因此全绿 —— 系统亲手把人送进一条必炸的死路,而唯一诚实的记录是文档表格里一句 `_(not yet wired)_`,没有作者会先读表格再写 body。 + + **裁决是 remove,不是实现**(维护者 2026-08-02):从未实现、调用即抛、**零投诉** —— 对一个每次使用都抛错的能力来说,这本身就是最强的活性证据,没人需要它。在沙箱里实现 crypto 会扩大沙箱的能力面与安全审查面,那是长期成本而非一次性工时,无业务拉动不做。真需要哈希时按能力准入流程重提:**实现先行,声明随实现走**(ADR-0049 的 enforce 腿留给有实现的那天)。 + + ## FROM → TO + + | 写了什么 | 现在怎么办 | + | :---------------------------------- | :------------------------------------------------------------------------------------ | + | `capabilities: ['crypto.hash']` | **删掉这个 token**。它从未授权成任何东西 | + | `await ctx.crypto.hash(algo, data)` | **删掉这次调用**。它从未返回过值 —— 今天能跑的代码没有一行依赖它 | + | 确实需要哈希 | 在 host 侧做(Connector recipe,或引擎侧 hook)。沙箱内哈希须走能力准入流程重开,实现先行 | + + 一句话修法:**两个都删**。`os migrate meta --from 16` 会自动帮你剥掉 token;那行**死调用是你自己要删的** —— 转换层刻意不改 body 源码(见下)。 + + ## 定级理由(逐条自证,未照抄前例) + + 三问按 #4535 §5 逐条走: + + 1. **会不会 TS2305 / TS2339?** 会,两处。`HookBodyCapability` 是 public 导出类型,把它当**字面量联合**用的代码(`const c: HookBodyCapability = 'crypto.hash'`、对 token 做穷举 switch)现在编译失败;`ScriptContext.crypto.hash` 的调用点以 TS2339 失败。实测三仓(objectstack / cloud / objectui)裸名扫描 `crypto.hash` / `ctx.crypto.hash` / `'crypto.hash'` —— **两个兄弟仓零命中**,本仓命中全在本 PR 内清理。 + 2. **有没有元数据迁移?** 有。token 是写在作者源 hook/action body `capabilities: []` 数组里的**值**,也会躺在已存的 `sys_metadata` 行里 —— 故注册了 ADR-0087 D2 转换 `hook-body-crypto-hash-removed`(D3 挂 protocol-17)。这是与 #4767 / #4783 / #4616 的分界:那三单退役的是**导出名 / 运行时描述符**,没有作者源可改写;本单有,和 `object-enable-trash-mru-removed` / #4734 同侧。 + 3. **形状变更?** 是**枚举值收窄**(6 → 5),不是 key 移除。故**没有 `retiredKey()` 墓碑** —— `capabilities` 这个 key 本身依然活着、依然被强制。处方改由枚举自己的 error map 承载,并按 `object.managedBy: 'system'` 的先例**以 `issue.input` 为键**:只有「曾经合法」的那个拼写会被告知「was removed」,写错成 `crypto.hsah` 的作者拿到的仍是 zod 自己那条列出合法 token 的消息 —— 告诉他「你的值被退役了」属于误导。 + + `@objectstack/cli` 与 `@objectstack/runtime` 同定 **major**:前者 `ExtractedBody.capabilities` 的公开联合类型收窄(赋值给它的代码 TS2322),后者 `ScriptContext.crypto` 少一个成员(TS2339)。 + + ## 门禁实报 + + 枚举值收窄对四张 ratchet **全部不可见**,这一点值得单独记一笔:`authorable-surface.json` 记到 key 级(`data/ScriptBody:capabilities`),`json-schema.manifest.json` 记 def 名(`data/HookBodyCapability` 仍在),`packages/spec/json-schema/` 本身 gitignore。所以 `check:authorable-surface` / `check:api-surface` 实跑**零变化**,`check:liveness` / `check:empty-state` 同样 PASS(`capabilities` key 仍活,不产生台账行变更)。 + + 也就是说:**本次移除没有任何一张基线能自动兜住它** —— 兜住它的只有本 PR 新增的 pin 测试(spec / cli / runtime 各一组,已 sabotage 实跑验证复活即红)。`check:generated` 8/8 绿,移动的是 `spec-changes.json`、`docs/protocol-upgrade-guide.md` 与两页生成参考文档(`data/hook-body.mdx`、`ui/action.mdx`,枚举选项随之少一项)。 + + ## 转换刻意不做的事 + + `hook-body-crypto-hash-removed` 只从 `body.capabilities` 里剥掉死 token,**不碰** body 源码里那行 `ctx.crypto.hash(...)`。这是有意的:那行调用从未返回过值,剥掉授权不会让任何还能跑的东西变坏;但把它一并「修好」会让作者失去唯一一个还在提醒他「这里有段死代码」的信号。`retiredFromLoadPath: true` —— 枚举当场拒绝,活作者在 parse 时就被教育,转换存在的意义是让已存的 16.x / 17-rc 行重放干净(否则永远被打成 `metadata_spec_invalid`,把链上历史误标成当期违约)以及让 `os migrate meta --from 16` 改写作者源。 + +### Minor Changes + +- 0800433: Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`). + + New advisory rule `action-no-placement`: an action that declares no + `locations` and that no list view places by name renders on **no** surface — + it parses, publishes, and appears in Setup, while no user can ever click it. + ADR-0078 names this shape in its opening paragraph and Phase 3 asks for + exactly this rule; the shared completeness predicate it envisioned was never + built, so this lands standalone, one verified shape at a time. + + What made it verifiable now: objectui#3142 collapsed four disagreeing + renderers onto one placement predicate. Before that, `action:bar` and the + record header rendered an _undeclared_ action anyway, so the shape only looked + inert on paper. As of objectui 17.1 it is measurably inert. + + Two things are deliberately **not** flagged: + + - **`locations: []`** — the documented headless action (callable over REST / + MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a + headless declaration is the only legal way to expose one. The rule therefore + distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key + absent) and only reports the latter. + - **Actions a view places by name** — `bulkActions`, `bulkActionDefs` + (including `execution: 'aggregate'` defs, whose whole point is an action with + no single-record home) and `rowActions`, across all three list-view tiers: + `views[i].list`, `views[i].listViews.` and the object-embedded + `objects[i].listViews.`. + + Advisory, never fatal — a view in another installed package may be the one + placing the action, the same reason `validateSemanticRoles` and + `lintLivenessProperties` warn rather than gate. + + Also: the action form schema in `@objectstack/metadata-protocol` no longer + declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()` + tombstones in spec 17, and this schema is what the Studio designer renders its + fallback form from — so advertising them handed authors two inputs that could + only ever produce an unsaveable draft (objectui#3145 removed the matching + dedicated controls). And `content/docs/ui/actions.mdx` now says which surface + is the exception to location filtering, instead of a blanket claim its own + showcase contradicted. + +- a7163ea: The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface. + + This closes the hole _between_ the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only _live_ properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green. + + **Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes. + + **One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed _only_ in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it. + + **Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing: + + | rule | the silent skip | severity | + | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------- | + | `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | + | `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error | + | `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | + | `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | + | `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning | + | `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning | + + **The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is _not_ flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass. + + **It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that _is_ complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets." + + Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time. + +- 4bee182: fix(cli): every author-time rule that can gate runs on all three commands (#4409) + + `os validate`, `os build` and `os lint` each hand-wired their own subset of the + author-time rules. Nothing connected the three lists, so "which rules run here?" + was answerable only by diffing three 800-line files by eye — and the answer + drifted every time a rule landed. The audit found 23 of 26 rules running on some + strict subset, nine of them able to emit `error`. + + The worst direction was the least obvious. `os build` — the command that + PUBLISHES — was the weakest gate of the three: a flow whose expression approver + does not parse (`approval-expression-invalid`) built and published green, and + only `os lint` stopped it, while CI usually runs the other two. `os lint` + disagreed in _both_ directions at once, running one gating rule neither other + command ran and missing six that both of them ran, which is worse than no + pre-flight — the remaining options are re-verifying everything or learning to + distrust the signal. + + This is the same failure mode's fifth appearance (#3583, #3782, #4384/#4394, + #4402). Each earlier repair removed an instance and left the MODE: a rule's + command coverage was whatever its author remembered to type, and forgetting was + silent. #4402's guard could not catch the rest — it filtered on the current + member names of one suite, so a rule hand-wired into two commands from outside + that suite passed it without a word. A name list only guards the names on it. + + **The registry.** `AUTHORING_RULES` declares all 26 rules as data: tier + (`gating`/`advisory`), which stack tier they read (pre-parse `normalized` vs + `parsed`), which commands run them, and a written reason for the one narrowing. + All three commands consume it through `runAuthoringRules()`, so adding a rule is + a one-line edit that reaches every command at once. The three command files + shrink by ~1000 lines between them. + + **The ratchet.** The wiring guard is no longer a name list: a `gating` rule on + fewer than three commands fails, a narrowed rule with no reason fails, a command + that calls or imports a registry rule directly fails, and an `advisory` claim is + checked against the rule's own source — so a gate cannot wear an advisory label + to buy itself partial coverage. That last check is the one #3760 needed, having + promoted a `lintFlowPatterns` rule from advisory to gating with nothing anywhere + asking whether its coverage should follow. Remaining direct calls are listed + with reasons, and a stale entry fails too, so the ratchet cannot rot into a + permanent permission slip. + + **The verdict, not just the wiring.** A separate test plants one defect per + previously-blind gating rule and asserts all three commands gate on it, plus the + issue's own repro driven end-to-end through the real CLI: exit 1 on all three + where it was 1/0/0. + + Two behaviour changes fall out of reporting every failing rule in one run + instead of exiting at the first failing gate: an author with three unrelated + problems now sees all three in one pass, and `--strict` covers every advisory + rather than the roughly half that happened to be printed inline. + + Also closes the same hole one gate over: `collectAndLintDocs` failed `os build` + and never ran on `os validate`, invisible because the parity guard keyed on the + `lint*`/`validate*` naming convention and that gate is called `collect*`. The + guard now names each shared non-registry gate explicitly instead of + pattern-matching for them. + + Cost is not what argued against any of this. The heavy dependencies + (`typescript` ~9 MB, `sucrase`) are already lazy and load only when a stack + carries the metadata that needs them, and the heaviest rule of the set has run + on all three commands as a reference-integrity suite member since #4340 without + anyone noticing. The one narrowed rule, `lintUniqueDeclarations`, is scoped + because `os lint` already reports it through `lintDataModel` — coverage + recorded, not coverage missing. + +- 5293114: fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) + + A `decision` node advertised three mechanisms for splitting a path and only one + of them did anything. The other two were the ADR-0049 `declared ≠ enforced` + shape, and the pair of them shipped a guard that does not guard in + `examples/app-crm`. + + | mechanism | before | now | + | :--------------------------------------------------- | :----------------------------------------------------------------------------------------------------- | :---------------------------------------------------- | + | `edge.condition` | ✅ the only one that worked | unchanged | + | `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | + | `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | + + ## What was broken, end to end + + `crm_convert_lead_wizard` means "already converted → abort screen; otherwise → + the wizard". It ran **both**: an already-converted lead got + "This lead has already been converted" and then walked straight into the + conversion wizard behind it. Four independent silences stacked up: + + 1. the decision's first condition was authored `{lead_record.status} == +'converted'` — braces in a slot declared bare CEL, so it was string-compared + and never true; + 2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; + 3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal + discarded the branch and considered every out-edge; + 4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking + it `isDefault: true`, was a dead key. + + ## The model + + `branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` + catches whatever is left. Concretely: + + - **`isDefault` is enforced.** A default edge is traversed only when no + conditional sibling of the same source node matched, and it is no longer part + of the unconditional parallel fan-out — that distinction is the whole point of + the marker. Passed over because a real branch won, its target records the same + `skipped` step a closed gate does (#4354). + - **An unclaimable branch label warns.** Traversal still falls back to the full + edge set (a run mid-flight must not die on a metadata error) but says so, + naming the computed branch and the out-edge labels that exist. + - **A decision that declares no `conditions` reports no branch.** It used to + report `'default'` unconditionally — a label no out-edge in the repo ever + carried — which is why every decision node fell back to the full edge set. + The `'default'` sentinel survives for the case it actually describes (declared + conditions, none matched) and is now claimed by the `isDefault` edge as well + as by an edge literally labelled `'default'`. + - **`conditions[].expression` is evaluated as the bare CEL it is declared to + be.** The raw string went to the legacy `{var}` template path, where + `lead.status == 'converted'` cannot resolve and the branch is decided by + string comparison. Unlike `edge.condition` this slot carries no + `ExpressionInput` envelope — the decision descriptor is deliberately + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate + now fails loudly (ADR-0032 §1c) instead of deciding `false`. + + ## Caught at authoring time too + + Four new `os build` / `os validate` warnings, because a wrong route is silent at + run time by nature (Prime Directive #12): + + `flow-branch-label-unmatched` (the shipped shape), + `flow-decision-unconditional-branch` (a guarded decision with an unconditional + sibling — the actual hole), `flow-default-edge-with-condition` and + `flow-multiple-default-edges`. + + Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent + after it. + + ## Effect on flows that already exist + + Enforcing `isDefault` changes how a **stored** flow behaves, and the flows it + changes are mostly Studio's own. `objectui`'s flow edge inspector has always + written `isDefault: true` when you bind an out-edge to a decision's default/else + branch — into a key with zero readers, so that edge ran unconditionally, in + parallel with whichever branch actually matched. Those flows now take exactly + one branch. That is the fix, but it is a behaviour change on existing data + rather than only on newly authored metadata, so it is worth knowing before + upgrading: a flow that quietly ran two paths will now run one. + + Nothing changes for an edge that never carried the marker — `isDefault` defaults + to `false`, and an ordinary unconditional out-edge still fans out in parallel + exactly as before. + + ## The example app + + `crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the + redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One + mechanism per decision, and exactly one branch runs. + + Verified: 11 new engine/executor tests (including the reported repro in both + directions), 12 new linter tests; `@objectstack/service-automation` 577 tests + and `@objectstack/cli` 652 tests green, all three example apps build with no new + findings. + +- d449b0c: fix(cli): gate the two decision-routing shapes that can never work, and flag the inert `config.condition` (#4414) + + Two follow-ups to #4440, both about metadata that reads like a guard and is not + one. + + ## Two rules promoted to `error` + + `flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the + build instead of warning. The bar for that — restated at the top of + `lint-flow-patterns.ts`, because the old one no longer described the set — is + **no reading of the author's metadata does what it says, deterministically, on + every run**. Both qualify: a branch label no out-edge carries cannot route, and + an edge that is both `isDefault` and conditional always lets the condition win, + so the marker routes nothing. Neither _fails_; both are wrong every time and + silently, which is worse. + + The other two stay advisory on purpose, and the policy now says why: + `flow-decision-unconditional-branch` is usually a guard that does not guard, but + one guarded plus one unconditional out-edge is also a legal "maybe notify, + always continue" fan-out, and `flow-multiple-default-edges` can genuinely mean + "when nothing matched, do both". The bar is about _provability_, not severity of + consequence — failing a customer's build on a shape we cannot prove wrong is the + worse trade. + + No wiring change was needed: `lintFlowPatterns` is already registered as + `tier: 'gating'` across all three commands (#4409), which is exactly the seam + `authoring-rule-wiring.test.ts` exists to guard. + + ## New rule: `flow-inert-node-condition` + + `config.condition` is the trigger gate on a `start` node and is read by **no + other node type** — the engine parse-validates it everywhere (so a malformed one + is caught) and then ignores it. On a `decision` the name makes it read as the + branch predicate, which is exactly how it got authored. + + Three of the three bundled apps had one. `app-todo`'s `check_recurring` and + `app-showcase`'s `needs_exec` both carried a predicate their out-edges were + already enforcing — a third copy doing nothing. The showcase even had a comment + next to it saying the node condition "is not evaluated by the engine", and kept + it anyway; that is the residue this rule exists to stop accumulating. Both are + now plain exclusive gateways. + + Advisory, not gating: the surrounding edges usually still route correctly, so + this is dead weight rather than a provable misroute. The node-type list is a + closed set of builtins we have actually read, not "everything that isn't + `start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately + declare and read its own `config.condition`. + + ## Studio + + `objectstack-ai/objectui` carries the matching help-text fixes: the branch editor + said a `true` branch **is** the default/else path (it is how you _ask_ for one — + the marker goes on the out-edge), and the legacy single `Condition` field said + "prefer Branches above", which reads as "this works, but the other is better". + It does not work at all. + +- eb4204b: feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + + The `script` executor's contract — _the named function returns a value; data I/O + stays on the flow graph_ — existed only as a comment inside the executor, while + #4354's run summary depended on it. That summary reports no record metrics for a + `script` step precisely because a pure function's writes are downstream + `create_record` / `update_record` nodes counting themselves. A function that + wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable + from the broken sweep the counters exist to detect, recorded permanently on + `sys_automation_run`. + + **The rule is now visible.** `ActionDescriptor` carries + `handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes + `'pure'`, so the action catalog, the designer palette and the reference docs + state the rule an author has to follow instead of an executor holding it + privately. + + **And a legitimate writer can opt out honestly.** A `defineStack({ functions })` + entry may declare what it does, in either shape: + + ```ts + defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: "writes" }, // declared writer + }, + }); + ``` + + A step calling a declared writer reports `unmeasuredEffect`, so the run's + `unmeasured` tally keeps the broken-sweep query + (`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that + flow. Marking _every_ `script` step unmeasured was rejected: it would blind the + detector on every flow that calls any function in order to cover the few that + break the rule. + + Nothing here is retired or renamed: a bare `functions: { fn }` entry is + unchanged and means `effect: 'pure'`. The declaration is carried end to end — + `ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the + existing `packageId` string and exposes `resolveFunctionEntry(name)`, + `objectstack build` lowers a declared entry without dropping it, and the + artifact loader re-attaches the module's callable to the declaration the JSON + carried. + + **Also fixed:** `bindHooksToEngine` returned before registering a bundle's + functions when the stack declared no hooks, so a flow-only app's + `defineStack({ functions })` reached the engine as nothing and every `script` + node calling one failed with "no function named 'x' is registered". + +- 63b33e6: Wire `validateFormLayout` into the authoring-rule registry, and close the + registry from the other direction (#4449). + + `validateFormLayout` was implemented, unit-tested, exported from + `@objectstack/lint` and given published rule ids (`form-field-unknown`, + `absolute-colspan-discouraged`) — and **no command ever called it**. It ran on + zero stacks for as long as it existed, so a form section referencing a field + that is not on the bound object, or pinning an absolute `colSpan` under a + per-surface derived column count, produced no output anywhere. It is now an + `advisory` entry in `AUTHORING_RULES`, so `os validate`, `os build` and + `os lint` all run it. It is a pure structured-metadata walk with no lazy + dependency, so all three commands pay nothing measurable. + + The wiring guard (#4409) could not have found this. Every one of its invariants + starts FROM a registry and looks at the commands, which is blind by construction + to a rule that never entered a registry — the same shape as #4402's name list + guarding only the names on it, one layer up. The guard now also runs the reverse + subtraction: every `validate*` / `lint*` symbol on `@objectstack/lint`'s public + barrel, minus `AUTHORING_RULES` ∪ `REFERENCE_INTEGRITY_RULES`, must be empty or + carry a written reason in `UNWIRED_RULE_LEDGER`. The ledger ships empty: today's + difference was exactly this one rule. + +- 071d0dc: feat(runtime,cli,core): boot reconciliation and `os migrate resume` for the migration journal — an interrupted run can no longer go unnoticed (ADR-0119 D2, #4617) + + Completes ADR-0119 D2. The runner and `sys_migration_journal` landed in #4668; this is the discovery channel that makes an interrupted run findable by someone who does not already know it happened. + + **`MigrationRecoveryPlugin` (`@objectstack/runtime`)** — at `kernel:ready`, scans the journal for runs that started and never concluded, and warns per run: how many chunks committed, which have an **unknown** outcome (`chunk_started` with no `chunk_done`), whether a compensation was left half-finished, and the exact command that will act. It also owns the `migration-plans` registry service. + + **`os migrate resume` (`@objectstack/cli`)** — lists interrupted runs (read-only, the default), or acts on one with `--run `, under confirmation. Exits non-zero when a run ends `failed`, so a scripted recovery cannot move on from a migration that needs a human. + + **`MigrationPlanRegistry` (`@objectstack/core`)** — where a resume finds the plan it has to re-run. + + ## Boot discovers, the CLI acts + + This is the design decision, and it is deliberate rather than incidental. + + Resuming is a large, irreversible, potentially hour-long write against production data. Doing that as an unrequested side effect of a process starting is the kind of behaviour an operator finds out about from a graph. It is also not always possible at boot: a resume needs the plan's live callbacks, and the package that owns them may not be loaded in whichever process happened to restart first. + + So boot surfaces the run and names the command; the command acts, under explicit operator intent. ADR-0119 D2's per-plan `onCrash` policy still decides **what** acting means — resume forward from the first chunk lacking `chunk_done`, or unwind what committed — it just does not decide **when**, and "when" is the part a human should own. + + Deferring is safe precisely because of the runner's re-entrancy: `started ∧ ¬done` is durable, so an interrupted run stays exactly as recoverable an hour later as it was at boot. Nothing decays while the operator decides. + + ## Why a plan registry exists at all + + A journal cannot hold a plan. `forward` and `compensate` are functions and `load()` reads the live database, so none of it crosses a process boundary — which is why the journal records the plan **hash**, not the plan. Recovery therefore needs the plan handed back by the code that owns it, and `migration-plans` is that seam: between "the journal knows a run stopped at chunk 7" and "something in this process knows what chunk 7 was supposed to do". + + A run whose plan no loaded package registers is **reported**, never silently skipped — the operator is told which plan id is missing. "Nothing to resume" and "the code that owns this run is not here" are different facts, and only one of them is safe to ignore. + + ## Degradation + + No engine, or no `sys_migration_journal` registered (a lean kernel that never composed platform-objects) → the scan is skipped in **silence**: such a kernel has no interrupted runs to find, and a warning there would train operators to ignore this plugin's output, which is the one thing it cannot afford. A scan that **fails**, by contrast, is reported — "I could not check" and "there is nothing to find" are different answers. + + 11 new tests pin the split (boot writes nothing to the journal), the three states an operator must tell apart (clean / interrupted / half-unwound), and both degradation paths. + +- 65f184b: fix(metadata)!: `sys_metadata_history.recorded_by` stores NULL, not the sentinel string `'system'` (#4556) + + `recorded_by` is declared `Field.lookup('sys_user', { readonly: true })` — a + foreign key. The write path filled it with `actor ?? 'system'`, so every + metadata write without a caller actor (boot sync, migration, an internal call) + stored the **string** `'system'` in a column whose declared type says "the id + of a `sys_user` row". No such row exists, and `SystemUserId.SYSTEM` + (`'usr_system'`) is not auto-provisioned on the current runtime either, so the + value resolved to nothing under any reading. Any consumer that read the field + by its declaration — `expand`, an owner column in a report, an audit timeline + showing "who changed this" — got an id that could not be dereferenced. + + It had already cost twice. #4441 had to exempt every `readonly` field from the + write-path referential-integrity check, because otherwise ordinary metadata + authoring (package create / publish / clone) was rejected. #4551's + dangling-reference audit had to skip the same set for the same reason. The + field ended up the platform's only reference column that is neither enforced + nor audited. + + **The fix is on the write path, not the declaration.** `recorded_by` stays a + `lookup('sys_user')`; an actor-less write now stores `NULL`, and `NULL` means + "system-initiated (boot sync, migration, scheduled job)" — the standard + expression of "no link", and already what this column's `set_null` delete + behaviour means. No magic system-user account (a row that can never sign in yet + holds an identity is a new security surface), and no `actor_kind` companion + column. + + **Breaking — the repository contract is now explicitly nullable.** + + | Surface | Before | After | + | :---------------------------------------- | :------- | :------------------------------------ | + | `PutOptions.actor`, `DeleteOptions.actor` | `string` | `string \| null` (still **required**) | + | `MetadataEvent.actor` | `string` | `string \| null` | + | `MetadataItem.authoredBy` | `string` | `string \| null` | + + `actor` stays required rather than becoming optional on purpose: every call + site must state which of the two it is, so a forgotten actor cannot silently + become a fake foreign key. Migrating a caller: + + - **Writers** — passing a real identity: unchanged. Passing `'system'`, `''`, + or a label to satisfy the type: pass `null` instead. + - **Readers** — `event.actor` and `item.authoredBy` can be `null`. Handle it at + the point of display (`actor ?? 'System'` in a UI string is fine — the fix is + that the _stored_ value no longer lies, not that no label may ever be shown). + + Two read paths also stopped inventing a value: `SysMetadataRepository.history()` + and `getByHash()` rendered an absent actor as the string `'unknown'`, which is + indistinguishable from a real user id to anything that resolves the field. They + now surface `null`. + + **Existing rows: `os migrate recorded-by`.** The stored `'system'` values are + rewritten to `NULL` by a new command, which runs the conversion through the + ADR-0119 D2 migration journal (chunk-atomic, resumable via `os migrate resume`). + It is a dry run by default and safe to re-run — it selects only rows still + holding the sentinel, so a second `--apply` converts nothing. + + The rewrite is **semantically equivalent, not a reinterpretation**: this column + has only ever held that one sentinel, written by exactly one expression + (`actor ?? 'system'`), and both spellings mean "no actor" — only `NULL` is + expressible in the declared type. + + Deliberately unchanged: `sys_metadata_audit.actor` is a `text` column whose + declaration already says "user id, system id, or `'system'`", so its `'system'` + default is honest and stays. The #4441 `readonly` narrowing and the #4551 audit + skip also stay — see the PR for why they are still correct. + +### Patch Changes + +- 8aacf94: fix(metadata-protocol): `duplicatePackage` stops minting pre-protocol flow rows (#4498) + + `duplicatePackage` canonicalizes each source row before re-saving it, under a + stated guarantee: "duplication never mints new rows in a pre-protocol dialect." + It delivered that through `convertStoredItem`, which opens with + `if (singular === 'flow') return { item: data, notices: [] }` — so for flows the + guarantee was **not** delivered. + + It did not fail loudly either. `FlowNodeSchema.config` is an open `z.record`, so + a pre-17 body (a `delete_record` carrying `config.filters`) sails through + `saveMetaItem`'s schema gate and lands verbatim in a brand-new row. + + **Why this mattered more than an un-migrated row.** ADR-0087 justifies the whole + stored-metadata design on new writes always being canonical, _therefore_ the + stored pass being "a strictly shrinking concern". `duplicatePackage` was a live + producer contradicting that for flows: an operator could run + `os migrate meta --stored --apply`, get a clean report, duplicate a package, and + be back to having pre-protocol rows — with the report still saying protocol N + until the next run. + + **The capability was already reachable.** The reason for the flow skip is real — + flow-node conversions carry ADR-0078's open-namespace conflict guard, which needs + the automation engine's live executor registry to tell a rename from a clobber. + But the protocol is constructed with an accessor for the kernel's service table + (the same one `analytics` and `package` are read from), and the automation + service registers under `automation`. A new private `resolveFlowCanonicalizer` + reads `canonicalizeStoredFlow` (#4454) off it, so every caller running next to a + live engine gets flow coverage without threading anything. + + - **`duplicatePackage`** canonicalizes flow rows through it. A refused rename + fails that item into the existing `failed[]` naming the token — copying the + un-renamed body would mint exactly the row this fixes. A flow that cannot + canonicalize fails the same way. With no engine reachable (a control-plane or + metadata-only host) the source body is copied as-is: no worse than the source + row already is, and failing an unrelated duplication over it would be its own + regression. + - **`migrateStoredMetadata`'s `canonicalizeFlow` becomes an override.** It now + defaults to the resolver. The CLI stopped passing one — it boots its inert + engine into the same kernel, so both routes reached the same instance, and two + routes to one capability is how they drift. The parameter stays for callers + with no registry and for testing the flow branch without an engine. + - **Resolution is lazy, per call.** Plugin init order does not guarantee + `automation` is in the table when the protocol is assembled (the CLI adds it + after ObjectQL by design), so caching `undefined` from a too-early read would + disable flow canonicalization for the life of the process. + + Two smaller honesty fixes ride along: a source item that fails _conversion_ (a + tombstoned key throws) is now reported as such instead of as `unparseable +metadata`, and `migrateStoredMetadata`'s "no engine" skip reason says no + automation service is reachable rather than blaming the caller for not supplying + one. + + Reads are unchanged. `getMetaItems` / `getMetaItem` / `getMetaItemLayered` / + `loadMetaFromDb` still skip flows — they are reads, covered by `registerFlow` + canonicalizing at execution, and are not producing bad data. Duplication was the + one that writes. + +- 4f13be2: Liveness coverage is complete: the nine remaining registered metadata types are + governed (#4488) — `app`, `book`, `doc`, `email_template`, `job`, `mapping`, + `seed`, `translation`, `validation` — and `PENDING_GOVERNANCE` is empty. Every + type in the metadata-type registry now has a ledger with per-property verdicts, + evidence, and a `verifiedAt` stamp. + + Spec: + + - Nine new ledgers under `packages/spec/liveness/` (≈150 verdicts). Highlights: + the ENTIRE `email_template` authoring surface is dead (nothing materializes + metadata items into the `sys_email_template` rows `sendTemplate` reads — an + admin editing the password-reset mail in Studio changes nothing; #4509); + `app.areas[].visible` / `areas[].requiredPermissions` are fail-open dead + gates (item-level siblings ARE enforced); `translation.validationMessages` + is read by nothing while #3778's own migration table steers authors into it; + `job`/`validation` have runtime-authoring doors disconnected from their + execution points (#4509). `doc` and `seed` are fully live. + - `check-liveness.mts`: the walker now sees through `z.preprocess` pipes + (takes the OUT side when the IN side is a transform) — `translation`'s + registered schema was unwalkable before this. + - `liveness/README.md`: the per-type count table's method is now decided and + recorded (it mirrors `check-liveness.mts --json` `byStatus`, the number CI + enforces); all rows regenerated from one run, and the two-generations-stale + `webhook` row rewritten to the post-#3489/#3494 state. + + CLI: + + - `lint-liveness-properties` registers the six newly governed types that carry + `authorWarn` entries (`apps`, `books`, `jobs`, `emailTemplates`, `mappings`, + `translations`), so authors hear about the misleading keys at compile time. + +- b25a116: fix(verify): resolve the enterprise organizations package from the HOST APP (#4700) + + `bootStack(app, { multiTenant: true })` — and therefore `objectstack verify +--multi-tenant` — could never load `@objectstack/organizations`. Node ESM + resolves a bare `import()` against the **importer's own realpath**, which for + `packages/verify` is inside the framework workspace, while the enterprise + package is cloud-private and only ever lives in the verified app's + `node_modules`. Every real host app fell into the catch and was told to + "Install/link it in this workspace" — about a package it had already installed. + Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed + that one call site and this issue tracked the two the sweep left behind. + + **New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` / + `createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts` + — where `@objectstack/verify` and the dogfood suite could not import it without + inverting the dependency direction — into a **node-only subpath export** of + `@objectstack/types`. One behaviour, one source; the CLI now consumes it and its + private copy is deleted. + + It is a subpath and **not** the root export because `@objectstack/types` is a + dependency of `@objectstack/hono` ("edge-compatible REST API server for + Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel` + boots on Workers. The root entry reaches zero `node:` builtins, and a Workers + bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two + entries as separate self-contained bundles (`splitting: false`), and a test + walks the root's import graph and fails on the first reachable `node:` + specifier, so the isolation is enforced rather than merely intended. Same + arrangement `@objectstack/metadata` already ships for its `./node` subpath. + + **New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names + the app whose `node_modules` supplies those optional packages — for a harness + booting an app that is not the working directory. + + **The dogfood multi-org gates had never run.** Two suites probed availability + with the same bare `import()` and so were **constant-false** — not "false + because absent" but false by construction, in every environment including the + cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and + the attachments cross-tenant isolation block had therefore never executed while + the suite reported green (Prime Directive #10, test-suite edition). They now + resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a + run is expected to ship the package — turning a silent skip into a loud failure, + so a run can no longer pass by quietly not running the gates it exists for. + +- 127f091: 修复:每个 `os migrate` 子命令关停后,#4551 悬空引用巡检都会把 `sys_metadata` / `sys_view_definition` 报成 `unreadableObjects`(#4747) + + 一条**成功**的命令过去会在返回 JSON 之后打出两行 `ERROR Find operation failed` 和一份 + `unreadableObjects` 非空的巡检报告 —— 对抓 ERROR 的 CI 流水线是直接误报源,更要命的是它把 + `unreadableObjects` 变成了恒为真的告警:那个桶存在的意义正是区分「我没能检查」和「我检查了, + 没问题」,一个每次健康运行都非空的桶不再携带任何信息。 + + 两处静默空转叠出了这个结果: + + - `ObjectQLPlugin` 的关停逻辑写在 `stop()` 里,而内核的插件契约是 `init`/`start`/`destroy` —— + `stop()` 从来没有被任何人调用过,ADR-0057 巡检定时器因此在任何宿主上都不会被解除。改为 + `destroy()`(与 `DefaultDatasourcePlugin` 一致)。 + - `bootSchemaStack().shutdown()` 调的是 `(runtime as any).stop?.()`,而 `Runtime` 根本没有 + `stop` —— 可选调用把「没有关停」伪装成了「关停过了」。改为走内核自己的 `kernel.shutdown()`, + 与 `os serve` 收到 SIGTERM 时同一条路径。 + + 同时 `LifecycleService.stop()` 不再只是清定时器:它还会把「引擎正在拆」这一位交给正在飞行中的 + sweep,巡检据此在读之前停手。因关停而失败的读**不再进入** `unreadableObjects` —— 那不是关于 + 数据源的证据;报告改用新增的 `DanglingReferenceReport.aborted` 记录「这次没跑完」,所以不完整 + 依然是响的,只是不再占用发现桶。 + + **真正读不出来的对象(数据源故障)照旧进 `unreadableObjects`**,巡检在 CLI 场景也照旧运行 —— + 这里没有「一次性命令不跑巡检」的开关,只有「引擎活着才读」的生命周期边界。 + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- 0f9faa2: The liveness gate now governs every registered metadata type (#4487) + + `GOVERNED` in `check-liveness.mts` was a hand-maintained list, and nothing ever + compared it against the registry it claims to cover. It governed **15 of 25** + registered metadata types while reporting itself complete. A type in the other + ten was authorable — served by `/api/v1/meta/types/:type`, editable in Studio — + and was never asked who reads its properties, so an inert key on it was + invisible to CI and its silence read as success. + + `datasource` was in that state for its entire life. #4410, #4465 and #4481 found + six inert keys on it **by hand**, two of them security-shaped: `schemaMode` was + dropped between the record and the connection spec, so a database ObjectStack + must never run DDL against was constructed as `managed`; `ssl` stopped at the + record, so a TLS block with a CA certificate in it configured nothing while + looking identical to one that worked. + + **The gate is now answerable to the registry.** Every registered type must be in + `GOVERNED` or in `PENDING_GOVERNANCE` with a reason and an issue. Registering a + type and forgetting the ledger fails CI with the entry to write. The reverse rots + too, so it also fails: a `PENDING_GOVERNANCE` row for a type that has since been + governed claims a debt that no longer exists. + + **`datasource` is now governed** — `liveness/datasource.json`, all 43 properties + classified with evidence. The result is the highest dead ratio of any governed + type: **20 of 43 have no runtime consumer.** + + | Dead cluster | Why | + | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `capabilities.*` (11) | The engine gates pushdown on the runtime driver's own `supports.*` object — `autonumber`, `batchSchemaSync`, `queryDateGranularity` — a different mechanism whose vocabulary does not overlap this block at all. `having-filter.ts` says it outright: "SQL pushdown can come later behind a driver capability flag." | + | `healthCheck.*` (3) | Nothing schedules a datasource probe. Liveness is checked on demand through the driver handle's `ping()`. | + | `retryPolicy.*` (4) | No connect or query path retries. | + | `external.label`, `external.requirePermission` | No reader. | + + **One correction ships with this**, and it is the reason the audit was worth + doing rather than a bookkeeping exercise. `capabilities.readOnly` reads as a + safety switch and gates nothing — and **two shipped prescriptions pointed + authors at it**: the `externalSettingsUnknownKeyError` guidance in + `datasource.zod.ts` ("or `capabilities.readOnly` to describe the driver") and + the #4465 changeset's relocation table. Both now name `external.allowWrites: +false`, which is the write gate the ObjectQL engine actually checks. An author + who followed the old advice believed they had marked a datasource non-writable + and had not. The v17 release notes carried a matching false claim — that an + unregistered `capabilities` key made the engine stop pushing work down to the + driver — corrected in the same change. + + Two traps worth naming, because both nearly produced a wrong verdict here: + + - **`healthCheck` and `retryPolicy` are name collisions.** A bare grep for + either returns plenty of live readers — the plugin health monitor, `hook`, + `job` — none of which is this type. `hook.retryPolicy` even spells its delay + `backoffMs` where this declares `baseDelayMs`; the shape mismatch is the tell + that nothing reads both. + - **objectui's `DatasourcePreview` renders `pool`, `ssl`, `retryPolicy` and + `healthCheck` as panels**, and is cited as evidence for none of them. That is + the standing rule in `liveness/README.md`, and #4481 is the fresh precedent: + the only "consumer" of `readReplicas` in either repo was a preview pill. + + The CLI advisory lint picks the ledger up automatically, so `os compile` now + warns an author who sets any of the 20. That needed one line beyond the ledger — + `datasource` had to be added to `TYPE_COLLECTIONS`. Coverage grows by marking + entries `authorWarn` only _within_ a type the lint already walks; a newly + governed type needs its collection registered or its ledger warns nobody. + + Nine types remain ungoverned and are now enumerated rather than implied: + `app`, `book`, `doc`, `email_template`, `job`, `mapping`, `seed`, `translation`, + `validation` (#4488). + +- 83cf2d3: feat(migrate,metadata-protocol): `os migrate meta --stored` rewrites sys_metadata rows so the read-path chain has a finish line (#4327) + + #4317 closed the correctness gap from the read side: every stored-row + rehydration seam replays the full ADR-0087 conversion chain, retired entries + included, so a row written under any past protocol is _served_ canonical + forever. What it deliberately did not do is make the rows themselves canonical. + A pre-17 row keeps its legacy bytes, the chain re-lowers it on every load, and + each affected row logs one conversion notice per process — deduped, but back + every boot. Until now the only things that ever rewrote such a row were a Studio + re-save and `duplicatePackage`. + + **`os migrate meta --stored`** is the pass that ends it for a deployment that + runs it. It walks `sys_metadata` — `active` and `draft`, every organization — + replays the same `applyConversionsToStoredItem` chain, and re-saves each changed + body through the normal write path, so a rewritten row gets a + `sys_metadata_history` entry, a fresh checksum and the mutation projectors, + exactly like an author's save. The history row's `source` is `migrate-stored`, + so a later diff distinguishes an upgrade from somebody's edit. + + ```bash + os migrate meta --stored # preview: per-row report, writes nothing + os migrate meta --stored --apply # rewrite the rows (prompts) + os migrate meta --stored --apply --yes --json # CI / scripts + os migrate meta --stored --type view # restrict to a type (repeatable) + ``` + + **Preview is the default and `--apply` is the only writing mode** — the house + rule its siblings already keep (#3617's "a dry run changes nothing"), and it + applies with more force here because what moves is metadata: every affected + row's checksum and a history entry per row. An apply run also refuses to start + while another process holds the SQLite database, for the same reason + `os migrate files-to-references --apply` does. + + **Nothing gates on this having run.** #3855's conclusion stands — an + operator-run migration cannot be relied upon, so the read path remains the + guarantee for every deployment, and no `sys_migration` flag is recorded (a flag + would advertise enforcement that does not exist). What a run buys is hygiene — + rows stop carrying pre-protocol dialects, so diffs, exports and history are + clean going forward, and the recurring notices go quiet — plus one thing that + was previously unobtainable: **an operator can assert it.** A run with nothing + left to do exits `0`, a deployment with rows still on an old dialect exits `1`, + so "my metadata is on protocol N" becomes a CI check rather than a belief. + + Three things the pass declines, and reports rather than counting as done: + `flow` rows (their seam is `AutomationEngine.registerFlow`, which holds the + executor registry the node-type conflict guard needs), types with no repository + write path (`agent` — rewriting there would record no history and force a draft + live), and rows that still fail the current schema after conversion (a genuine + contract violation the write path is right to refuse; it keeps reading through + the chain and stays fixable in Studio). + + Also new, and usable without the CLI: `protocol.migrateStoredMetadata()` returns + the same structured report an admin route would render, and `saveMetaItem` + accepts an optional `source` for the history/audit rows. `source` is not + request-derived — the REST layer builds its save request field by field and + never forwards a client-supplied value, so provenance stays something the server + states rather than something a caller claims. + +- 4b945fc: Author-time rules now gate the RUNTIME metadata write path, not just the CLI (#4463) + + The 26 author-time rules `os validate` / `os build` / `os lint` share (#4409) ran on + those three commands and nowhere else. Every runtime metadata write — Studio's + designer, REST `/meta` item CRUD, an MCP/AI agent authoring a flow — reaches + `saveMetaItem`, which did a per-type Zod `safeParse` and stopped. For a tenant that + was not the weakest of four doors, it was the **only** door: a `sys_metadata` + overlay row is not in the CLI's config file, so there was no command they could run + instead. An approval flow whose `expression` approver is broken CEL + (`record.owner ==`) is Zod-valid, so it saved, registered, and failed at the node's + entry the first time it fired — the exact body `os lint` had rejected since #4409. + + **One shared core, one runtime gate.** + + - The rule registry moved from `packages/cli` into `@objectstack/lint` + (`AUTHORING_RULES`), and the CLI now calls it there. Five rule modules moved with + it (`lintFlowPatterns`, `lintLivenessProperties`, `lintAutonumberFormats`, + `lintViewRefs`, `data-model-rules`), unchanged. There is one table; a second one + cannot be introduced without failing `authoring-rule-wiring.test.ts`. + - New kernel-safe subpath export **`@objectstack/lint/runtime`** — the entry the + metadata write path imports. Running the gate loads neither `typescript` nor + `sucrase`, pinned by a new `runtime-lazy-deps.test.ts` alongside the existing + `lazy-deps.test.ts`, which is unchanged. + - Each registry entry now declares `surfaces` (`cli` / `runtime-publish`) plus + either the metadata `runtimeTypes` it judges or a written `surfaceReason`. The + ratchet fails an entry that answers neither. + + **Behaviour** + + - A `state: 'active'` `saveMetaItem` — and the draft→active promotion in + `publishMetaItem` — of a **flow** runs the flow / approval / expression / + reference rule families. A gating finding is refused with **422 + `INVALID_METADATA`**, in the same structured envelope the Zod failure already + used, with `rule` / `path` / `where` / `message` / `hint` per issue. + - **Draft saves are never gated** — a draft is allowed to be half-finished and + cannot execute. + - Only the write is judged: the rules run twice (context with and without the + submitted item) and only findings the item _added_ can refuse it, so a + pre-existing violation in a stored row never blocks an unrelated save. Stored + rows keep being read. + - Escape hatch **`OS_ALLOW_UNLINTED_METADATA_WRITES=1`** turns the refusal into a + loud log for a migration window. Unset it once the metadata is fixed — the + runtime executes what it published. + + Only `flow` writes are gated in this pass; every other metadata type carries a + recorded reason in the registry. + +- 16fc124: fix(cli): `objectstack serve` resolves the enterprise multi-org runtime from the app, not from the framework (cloud#1013) + + Any self-hosted deployment that requested a walled tenancy posture + (`OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=1`) refused + to boot: + + ``` + ✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations + could not be loaded, so the organization wall is INACTIVE. Refusing to boot. + cause: Cannot find package '@objectstack/organizations' imported from …/packages/cli/src/commands/serve.ts + ``` + + …however the package was installed. `serve` loaded it with a **bare** + `import('@objectstack/organizations')`, and Node ESM resolves a bare specifier + against the **importer's own realpath** — the CLI's, inside the framework + workspace it is linked out of. `@objectstack/organizations` ships in the cloud + distribution and lives in the _served app's_ `node_modules`, so that import + could never succeed and declaring the dependency in the app changed nothing. The + only way past the ADR-0093 D5 fail-fast was `OS_ALLOW_DEGRADED_TENANCY=1`, i.e. + booting with the organization wall inactive — exactly the state D5 exists to + prevent. + + The load now goes through the same host-app resolver `serve` already used for + the AI service packages (`createHostImporter`, extracted to + `src/utils/import-from-host.ts`): resolve from the host app's root, import the + resolved path, and fall back to the CLI's own resolution only for the + framework-owned packages the CLI itself depends on. **Declare + `@objectstack/organizations` in your app's `package.json`** and a walled posture + boots. + + Two smaller changes ride along: + + - A package the host resolves but that **throws while it loads** now propagates + its real error instead of being re-imported bare and reported as + `MODULE_NOT_FOUND` — a broken package used to be misreported as a missing one + (silently skipped for optional services, or a fatal telling the operator to + install what was already installed). + - The D5 fatal now names _the app_ as the place the package has to go. + +- 29326f8: fix(cli): `os serve` 区分「多组织包缺席」与「插件自己拒绝挂载」(#4818) + + `os serve` 在走 walled posture(`OS_TENANCY_POSTURE=group` / `isolated`)时, + 把 `importFromHost('@objectstack/organizations')` 和 + `kernel.use(new mod.OrganizationsPlugin())` 放在**同一个 `try`** 里,于是插件在 + **构造 / 挂载**阶段抛出的任何错误都被当成「包加载不出来」上报:文案说 + `@objectstack/organizations could not be loaded`,给出的出路里包含 + `OS_ALLOW_DEGRADED_TENANCY=1`,而该 env 已设时更会把它**降级成一条 warning 并继续启动**。 + + 这是两件事,解法相反: + + | 事实 | 解法 | `OS_ALLOW_DEGRADED_TENANCY` | + | ------------ | ---------------------- | ------------------------------- | + | 包缺席 | 装上它 / 改单组织 | 适用(operator 明确接受能力缺席) | + | 插件拒绝挂载 | 按插件自己报的原因处理 | **不适用** | + + 合并后的代价是实打实的:包明明在镜像里,日志却把人指向模块解析 / `NODE_PATH` / + 依赖 prune;更糟的是那条逃生口会吞掉插件自己的拒绝,等于把插件在守的闸门搬到一个 + env 变量上。 + + 现在按**哪个阶段抛错**分类(不看错误形状 —— 该包是 `importFromHost` 动态加载的, + CLI 与它可能持有不同模块实例,`instanceof` 和具名 `code` 判据都脆;framework 也不该 + 编码插件的私有语义): + + - **import 阶段失败 = 包缺席** —— 行为完全不变:同样的 ADR-0093 D5 文案, + `OS_ALLOW_DEGRADED_TENANCY=1` 依旧可以显式降级启动。 + - **构造 / 挂载阶段失败 = 插件自己拒绝** —— 原样上报插件的错误(message,以及它自带的 + `code`,通用打印、不作解释),明说包**已找到并加载**、不必去查模块解析,并声明 + `OS_ALLOW_DEGRADED_TENANCY` 对这条路径**不适用**;**无条件 `process.exit(1)`**。 + + ADR-0093 D5 的态度不变:要求了隔离就不能假装有,仍然拒绝启动 —— 变的只是「为什么拒绝」 + 和「告诉 operator 什么」。唯一的行为变化是 `OS_ALLOW_DEGRADED_TENANCY=1` 不再能让一个 + 拒绝挂载的多组织插件被吞掉并继续启动;若你此前依赖这一点,请改用 + `OS_TENANCY_POSTURE=single`,或处理插件报出的原因。 + +- 304423e: feat(automation,migrate): `os migrate meta --stored` now covers flow rows too (#4454) + + #4327 gave the stored-metadata conversion chain a finish line for every + metadata type except `flow` — the one type where the most stored dialect + actually lives, since the graduated conversions `flow-node-crud-filter-alias`, + `flow-node-crud-object-alias`, `flow-node-notify-config-aliases` and + `flow-node-script-config-aliases` are all flow-node entries. Flow-node + conversions carry ADR-0078's open-namespace conflict guard, which has to consult + the _live_ executor registry to tell a rename from a clobber, and the metadata + layer has no way to obtain one. Flows were reported `skipped` with that reason. + They are now converted. + + **One canonicalization policy, two shapes.** + `AutomationEngine.canonicalizeStoredFlow` is the single implementation and + `registerFlow` calls it, so the load seam and the migration can never disagree + about what "canonical" means. It returns `parsed` (for execution — the + `FlowSchema.parse` + #4347 region output, schema defaults materialized) and + `storable` (for persistence). + + **`storable` excludes schema defaults, and that is the load-bearing decision.** + Measured rather than assumed: driving a pre-17 flow through all three steps + _removes_ nothing — `FlowSchema` is strict since #4001, so an unrecognized key + throws instead of being silently dropped, which means the + `graftNormalizedOperators` precedent (it exists because the _view_ parse strips + Studio-only auxiliary keys) does not transfer — and _adds_ only defaults: + `version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the + author never wrote would pin every migrated row to today's value while untouched + rows follow tomorrow's: two populations with different behaviour, which is + exactly the drift this pass exists to remove. So the write-back is the + conversion result plus the `{dialect, source}` envelopes the schema derives for + edge conditions, and nothing else. + + One subtlety worth knowing if you extend this: that envelope is a schema + transform, not a conversion, so it emits **no** notice while still changing the + body. Reading notices alone — correct for every other metadata type — would call + such a row canonical and leave it re-deriving on every boot. Both passes are + copy-on-write, so identity is the exact test for flows. + + **New: `AutomationServicePluginOptions.armRuntime`** (default `true`, so every + server, dev stack and test host is unaffected). Set `false` and the plugin + brings up the engine and the complete node registry — built-ins plus whatever + `automation:ready` contributes, because a _partial_ registry would make the + conflict guard read a live custom node type as unowned and rewrite over it — and + then stops before anything is armed: + + | Skipped when `armRuntime: false` | Why it must be | + | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | + | flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live | + | declarative connector materialization | opens real connections; an MCP provider spawns a child process | + | suspended-run wait-timer re-arm | would resume someone's paused approval mid-migration | + + `os migrate meta --stored` boots the plugin in that mode. A migration process + must not become a second server. + + A refused rename — the guard firing because the old node-type token is a live + name something else owns in this environment — fails that row loudly, naming the + token and its owner. Never a silent skip, never a clobber. A flow that cannot + canonicalize at all (a strict-schema violation, a malformed control-flow region) + is reported as failed with the parse message rather than persisted as a guess; + such a row cannot register today either, so the report is telling you about a + flow that is already broken at runtime. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [0800433] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [85a966f] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [2f05139] +- Updated dependencies [fa94b2c] +- Updated dependencies [328ccc5] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [0b795da] +- Updated dependencies [c2a1134] +- Updated dependencies [941dec4] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2eb850] +- Updated dependencies [8bd437f] +- Updated dependencies [5046afe] +- Updated dependencies [203a449] +- Updated dependencies [6dcbbc3] +- Updated dependencies [ac37fc6] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [58434f5] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [84b4a3a] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [072806a] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [c4ab50b] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [8aacf94] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [05d8a54] +- Updated dependencies [9b43ee2] +- Updated dependencies [ec975f1] +- Updated dependencies [68c02c2] +- Updated dependencies [eb4204b] +- Updated dependencies [25784cf] +- Updated dependencies [4f13be2] +- Updated dependencies [459f925] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [8e53e5d] +- Updated dependencies [0f9faa2] +- Updated dependencies [4c80fd6] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [040ecd2] +- Updated dependencies [63b33e6] +- Updated dependencies [8aacf94] +- Updated dependencies [6beb708] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [83cf2d3] +- Updated dependencies [071d0dc] +- Updated dependencies [beefe89] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [69b509f] +- Updated dependencies [7e05d8e] +- Updated dependencies [0d9a779] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [4b945fc] +- Updated dependencies [1ee48bc] +- Updated dependencies [705e5c8] +- Updated dependencies [f61edce] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [0657f6b] +- Updated dependencies [666f542] +- Updated dependencies [21676eb] +- Updated dependencies [ba5ff2f] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [304423e] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [be90dea] +- Updated dependencies [04b9776] +- Updated dependencies [04f1182] +- Updated dependencies [c03108c] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [c2a1134] +- Updated dependencies [48fbacb] +- Updated dependencies [24915d2] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/lint@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/metadata-protocol@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/plugin-auth@17.0.0-rc.2 + - @objectstack/plugin-audit@17.0.0-rc.2 + - @objectstack/plugin-security@17.0.0-rc.2 + - @objectstack/plugin-webhooks@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/service-analytics@17.0.0-rc.2 + - @objectstack/service-automation@17.0.0-rc.2 + - @objectstack/plugin-approvals@17.0.0-rc.2 + - @objectstack/rest@17.0.0-rc.2 + - @objectstack/service-storage@17.0.0-rc.2 + - @objectstack/client@17.0.0-rc.2 + - @objectstack/console@17.0.0-rc.2 + - @objectstack/driver-sql@17.0.0-rc.2 + - @objectstack/driver-memory@17.0.0-rc.2 + - @objectstack/driver-mongodb@17.0.0-rc.2 + - @objectstack/metadata@17.0.0-rc.2 + - @objectstack/service-datasource@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/plugin-sharing@17.0.0-rc.2 + - @objectstack/plugin-email@17.0.0-rc.2 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/verify@17.0.0-rc.2 + - @objectstack/service-job@17.0.0-rc.2 + - @objectstack/service-queue@17.0.0-rc.2 + - @objectstack/service-settings@17.0.0-rc.2 + - @objectstack/service-messaging@17.0.0-rc.2 + - @objectstack/plugin-hono-server@17.0.0-rc.2 + - @objectstack/cloud-connection@17.0.0-rc.2 + - @objectstack/account@17.0.0-rc.2 + - @objectstack/setup@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + - @objectstack/mcp@17.0.0-rc.2 + - @objectstack/plugin-reports@17.0.0-rc.2 + - @objectstack/service-cache@17.0.0-rc.2 + - @objectstack/service-package@17.0.0-rc.2 + - @objectstack/service-realtime@17.0.0-rc.2 + - @objectstack/service-sms@17.0.0-rc.2 + - @objectstack/trigger-api@17.0.0-rc.2 + - @objectstack/trigger-record-change@17.0.0-rc.2 + - @objectstack/trigger-schedule@17.0.0-rc.2 + - @objectstack/plugin-pinyin-search@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 872fa8af04..3217acfb2b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 e0dd1ddad2..f54d77fe67 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,225 @@ # @objectstack/client-react +## 17.0.0-rc.2 + +### Minor Changes + +- 0884452: feat(client-react): bulk-write hooks, and `useAutoRefresh` now refreshes on predicate writes (#4678) + + #4639 gave predicate writes (`multi: true` update/delete) their own event + contract — `data.records.updated` / `data.records.deleted`, carrying a + `matched` count and no record — and `@objectstack/client` exposes them via + `subscribeBulkData`. The React hooks never caught up: all three realtime data + hooks delegated to `subscribeData`, so React consumers could not see bulk + writes at all. + + The sharpest edge was **`useAutoRefresh`**. Its whole job is "refetch when the + data changes", and a predicate write is the case that dirties a list hardest — + one statement can change or delete every row on screen. It sat still for those + while refetching dutifully for a single-row edit. + + - **New `useBulkDataSubscription(object)`** returning the latest + `BulkDataEvent`, and **`useBulkDataSubscriptionCallback(object, cb)`** for + the refetch/side-effect case. + - **`useAutoRefresh` now watches both streams.** Safe here in a way it is not + for `useDataSubscription`: this hook's output is a refetch signal, not an + event body, so the shape difference that keeps the two contracts apart never + reaches the caller. When `options.recordId` narrows it to one record it still + refetches on a bulk event — a count cannot say whether that record was in the + match set, and a redundant query beats showing a row a predicate write + already changed. + - **`useDataSubscription` / `useDataSubscriptionCallback` are unchanged** and + still per-record only. Their callbacks are typed `(event: DataEvent) => void`; + letting a `BulkDataEvent` through would hand them an object whose `recordId` + and record body are `undefined` — the defect #4626 removed. + +### Patch Changes + +- 21855f8: fix(client-react): stop five hooks from looping on dependency identity (#4693, #4694) + + Five hooks keyed a `useCallback`/`useEffect` on values the caller supplies + inline — `where` / `fields` / `orderBy` objects, `onSuccess` / `onError` + handlers, and the `fetcher` `useMetadata` takes as a required positional + argument. Inline means a fresh identity on every render, so the effect re-ran on + every render; because the fetch hooks call `setState`, that render caused + another. The result was an unbounded request loop under the hooks' own + documented usage. + + Requests issued in 250ms by a single mounted component, measured before and + after: + + | hook | before | after | + | ----------------------------------- | -----: | ----: | + | `useQuery` (inline `where`) | 4691 | 1 | + | `useInfiniteQuery` (inline `where`) | 6611 | 1 | + | `useObject` (no options at all) | 4306 | 1 | + | `useView` (inline `onSuccess`) | 8197 | 1 | + | `useMetadata` (inline `fetcher`) | 7654 | 1 | + + `useObject` and `useMetadata` needed no particular usage to loop: the former + depended on its own `data` and `etag` state while writing both, and the latter + takes its fetcher positionally, so there is no non-inline way to call it. + `useMutation` was never affected — no effect drives it. + + The same root cause churned the realtime subscriptions (#4694): + `useAutoRefresh` with an unmemoized `refetch` — which is what `useQuery` + returned on every render — resubscribed on both streams every render, losing any + event delivered in the unsubscribe/resubscribe gap. + + Two internal primitives fix both halves: `stableKey` derives a dependency from a + structural value (sorted keys, array order preserved) so a rebuilt-but-equal + object is a no-op, and `useEventCallback` gives a handler a fixed identity while + always invoking its latest version. Neither is exported. + + A changed _value_ still refetches, and every stabilized handler is asserted to + run its newest version rather than the one captured when the effect first ran — + the ref indirection would otherwise trade a loop for a stale closure. 13 tests + cover this, each verified by reverting the fix it guards. + +- bbb1192: test(client-react): give the package a test harness and pin the realtime hooks' behavior (#4682) + + `packages/client-react` shipped 8 public hooks with `build` and `typecheck` as + its only scripts and not a single test file. `tsc --noEmit` cannot see any of + what actually breaks in a hook: a dependency array is a value, not a type, so a + missing entry, a missing cleanup, and a callback that never fires all typecheck + perfectly. #4678 was precisely that shape — `useAutoRefresh` ignored predicate + writes, the one case that dirties a list hardest, and no type noticed. + + Adds the workspace's first DOM test environment (`jsdom` + `@testing-library/ +react`, `environment: 'jsdom'` — every other package runs `node`) and 17 tests + over the realtime hooks, covering the three things the type checker is blind to: + + - **Re-subscription on dependency change** — changing `object` opens a + subscription on the new name and releases the old one; a re-render that + changes nothing must not churn. Also pins that the hooks key on the primitive + `options?.recordId` / `options?.packageId` rather than on the options object's + identity, so an equal-but-new object stays a no-op. + - **Release on unmount** — every subscription hook unsubscribes, including + `useAutoRefresh`, which holds two. + - **Delivery** — events reach state and callbacks, and `useAutoRefresh` + refetches on the per-record _and_ the bulk stream (the #4678 regression pin). + + Each assertion was verified by sabotage: dropping the `object` dep, deleting a + cleanup, and reverting `useAutoRefresh` to the single-stream version each turn + the suite red, and only reverting turns it green again. + + The package is picked up by CI's `Test Core` shards automatically — they + partition by package off `turbo ls`, so a `test` script is all that was needed. + + No runtime code changed. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [84b4a3a] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/client@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 8c7f8199dd..8392fae2dd 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.1", + "version": "17.0.0-rc.2", "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 0b99eebaaa..676e181a63 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,395 @@ # @objectstack/client +## 17.0.0-rc.2 + +### Major Changes + +- dadb43f: refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451) + + The `workflow` slot was ADR-0078's silently-inert declaration at every layer at + once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115 + Evidence 5 — "no code in this repository resolves either slot", verified across + both repositories), an `IWorkflowService` contract with zero implementations, a + `WorkflowProtocol` whose three methods no code ever provided, a discovery + `routes.workflow` field no builder could truthfully populate, and a + `/api/v1/workflow` advertisement for a path no host ever mounted (the + pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that + never existed). The capability it promised is live elsewhere and has been for + majors: record state machines are enforced by the `state_machine` validation + rule, approvals are first-class flow nodes on the approvals runtime + (ADR-0019), and record-triggered automation is lifecycle hooks + + `record_change` flows (`service-automation`). + + FROM → TO: + + - `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` / + `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill. + - `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no + implementation ever existed. Register nothing — use the mechanisms above. + - `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`, + `WorkflowState`, `GetWorkflowStateRequest/Response`, + `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed, + along with the seven published JSON schemas. Delete the import; nothing + ever answered these shapes. + - Discovery `routes.workflow` / `services.workflow` / `features.workflow` + (metadata-protocol + runtime builders) → absent. A reader keying on them + only ever saw `unavailable` / `false`; delete the read. + - `RouterConfig.mounts.workflow` → removed; there was never a surface to + mount at it. + - `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent + routes as `'automation'`. + - `@objectstack/client` re-exports of the four workflow types → removed with + their source. (The `client.workflow.*` methods were already removed earlier + in the v17 cycle — this retires the types they returned.) + - Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the + `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a + `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out + of the product plan (#2462 follow-on). + + The retirement kit: the `workflow-service-slot-retired` semantic migration + (major 17) carries this prescription into `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a + discovery response field — never stored in stack metadata — so there is no + load-path conversion and nothing for `os migrate meta` to rewrite; the + 21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` + entries for the deleted schemas are dropped deliberately in the same change + (the plugin-runtime precedent: a prescription nobody can receive is noise — + nothing parses these shapes any more). + +### Minor Changes + +- f2445c9: feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) + + A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, + which are contracted to resolve an affected row COUNT and nothing else. That + satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / + `changes`, so before #4626 the engine fabricated a per-record event with + `recordId: ''` and `after: ` — an event every schema-compliant consumer + must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into + a real delivery naming an unidentifiable record. #4626 removed the fabrication + and published nothing instead: honest, but it left webhooks, knowledge sync and + `subscribeData` silent for every predicate write. + + Bulk writes now get their **own** contract rather than impersonating a + per-record one or going dark: + + - **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from + `DataEvent`, not a widened one: a consumer that receives + `data.records.updated` knows from the type alone that no `recordId` is + coming, instead of discovering an empty string at runtime. + - **Engine** publishes it from the `multi: true` branches of `update()` / + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A + predicate that matched **zero** rows publishes nothing (no data changed — this + is what keeps an idle background sweep from becoming an hourly "0 records" + delivery), and a driver that resolves a non-count publishes nothing and warns + rather than asserting a number it cannot verify. Per-record writes are + untouched, including a scalar `where.id` with `multi: true`, which is still a + single-record target and still emits `data.record.deleted`. + - **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are + **not** extra sources for `create` / `update` / `delete`: the delivered body + has no `recordId` and no record, so routing it to existing per-record + subscribers would hand them a payload missing every field they read — the + same class of breakage as the old `recordId: ''`, from the other direction. A + webhook that wants both subscribes to both. Bulk deliveries dedup on the + producer's event uuid, since two sweeps in the same millisecond are genuinely + different events that a timestamp-based key would collapse. + - **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the + same loud boundary validation as `subscribeData`. Kept a separate method for + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => +void` callback would recreate exactly the "typed field, `undefined` at + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened + from `data.` to `data.record.`, so an aggregate event is ignored rather than + rejected as off-contract. + - **Knowledge sync** now says out loud that a predicate write leaves its index + stale. A knowledge index is a per-record projection and `matched: 40` names no + record, so no event shape could drive it — the durable fix is reconciliation, + tracked in #4672. + + The event carries no `where` predicate. The only one available at publish time + is the middleware-composed AST, whose filter embeds the security layer's + injected row scoping (RLS, sharing) — publishing it would ship tenant scoping + internals to whatever external URL a webhook points at. + + Also pays off a measurement debt from #4655, which claimed the write-path cost + of event publishing had been measured but never published the numbers: + `packages/objectql/src/engine-data-events.bench.ts` measures it. Against an + in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, + single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless + of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its + relative cost shrinks as the match set grows. + +- 8aacf94: feat(rest,runtime,client): `POST /meta/_migrate-stored` — run the stored-metadata migration without a shell (#4327) + + `os migrate meta --stored` (#4327) gave ADR-0087's stored-metadata chain a finish + line, but only for someone who can reach the deployment's database from a + terminal. A hosted operator cannot, so on a managed deployment the chain had no + finish line at all — just the per-read conversion, running forever, with no way + to assert what protocol the rows are on. + + The same pass is now reachable over HTTP: + + ```ts + const preview = await client.meta.migrateStored(); // writes nothing + const result = await client.meta.migrateStored({ apply: true }); + const flows = await client.meta.migrateStored({ types: ["flow"] }); + ``` + + It returns the same `StoredMigrationReport` the CLI renders, and takes the same + posture: + + - **Preview by default.** `apply` must be literally `true`; an empty body, a + missing body, and `"apply": "yes"` all preview. Nothing is inferred. + - **Gated on `manage_metadata`.** Unlike the single-item `PUT /meta/:type/:name` + next door, this rewrites every eligible row in the deployment, so it demands + the ADR-0066 D1 authoring capability rather than just a session, and answers + `403` otherwise. The gate runs before the protocol is probed, so an + unauthorized caller cannot use `403`-vs-`501` to learn which kernels can be + migrated. `/meta`'s anonymous-deny umbrella still closes it to anonymous + callers first. + - **Attributed to the caller.** The `actor` recorded on the history and audit + rows names the user who fired it — that is the question those rows exist to + answer. + + **Flows need no extra setup on this path.** The CLI has to boot an inert + automation engine to hold the executor registry ADR-0078's conflict guard needs; + a server already has a live one, and the protocol resolves it from the services + registry itself (#4498), so this route covers flow rows by simply running in the + process that owns them. + + Registered on both the REST server and the runtime dispatcher's `/meta` domain, + ledgered in both route ledgers, and mounted before `/:type` so the + leading-underscore segment is never captured as a metadata type name. + +### Patch Changes + +- 84b4a3a: docs(client): drop the retired `validateOnly` batch option from the README (#4052) + + The Batch Options section still documented `validateOnly` as a working dry-run — + "validate records without persisting changes" — but the key was retired in #4052 + precisely because nothing ever read it. Every batch surface (`updateManyData` / + `deleteManyData` / `batchData`) persisted regardless, so a caller who sent it to + preview a mutation got that mutation **executed**. + + `BatchOptionsSchema` has carried a `retiredKey(...)` tombstone since #4052, so the + schema already refuses the key loudly. The README was the last place still + promising it — declared-but-not-enforced in prose rather than in code, aimed at + exactly the readers who cannot see the tombstone. + + Released as a patch rather than declared release-nothing because `README.md` is in + this package's `files`: the corrected text only reaches the people who hit the + problem — readers on npmjs.com — if the package ships. + + Replaced with a pointer to `docs/protocol-upgrade-guide.md` + (`batch-options-validate-only-retired`). No behaviour change; there is no batch + dry-run today. Write-path validate-only was evaluated in #4372 and closed as not + planned — no current consumer justifies the surface. + +- 462b713: fix(objectql,client): `subscribeData` callbacks receive real `DataEvent`s — the producer now fulfils the declared contract (#4626) + + `@objectstack/spec/api`'s `DataEvent` declares top-level `id` (uuid, + required), `type`, `object`, `recordId` (required), `changes?`, `before?`, + `after?`, `userId?`, `timestamp`. But the producer (the ObjectQL engine) + published a raw `RealtimeEventPayload` envelope with `{ recordId, after, +changes }` nested under `payload` and never generated `id`/`userId`, while the + client SDK force-cast that envelope into the callback (`callback(event as any +as DataEvent)`). Subscribers who wrote `event.recordId` / `event.changes` — + exactly what the types promised — compiled green and read `undefined` at + runtime. The data-side twin of #4602. + + Producer now fulfils the contract: + + - `ObjectQL.insert()` / `update()` / `delete()` build a true `DataEvent` + (generated uuid `id`, flattened top-level fields, `userId` from the + execution context when the write names an actor) and validate it with + `DataEventSchema.parse` before publishing. The transport envelope is + unchanged (`RealtimeEventPayload`, with `payload` carrying the complete + `DataEvent`), so subscribers keep receiving `{ type, object, payload, +timestamp }` on the wire. + - A batch insert publishes one event **per record** (as before), each with its + own event id. + - **A multi-row write (`multi: true` → `updateMany` / `deleteMany`) now + publishes nothing.** Those driver methods return only an affected count, so + there is no record for a required `recordId` to name; the engine logs a + warning naming the gap instead of publishing the previous fabrication + (`recordId: ''`, `after: `), which every schema-compliant + consumer had to reject. **Consequence: webhooks and knowledge sync no longer + fire for bulk writes** — they previously fired once with an unusable body. A + real bulk event contract is tracked in #4639. + + Consumers validate or read the fulfilled shape instead of guessing: + + - `@objectstack/client`'s `subscribeData` (and therefore + `@objectstack/client-react`'s `useDataSubscription` / + `useDataSubscriptionCallback` / `useAutoRefresh`, which delegate to it) + unwraps the envelope and runs `DataEventSchema.safeParse` at the boundary. + An off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as DataEvent` + double-cast is gone, and the `recordId` option now filters on the fulfilled + event. + - `@objectstack/plugin-webhooks`' auto-enqueuer reads the required + `recordId` directly; its `recordId ?? id ?? after?.id ?? before?.id ?? +'unknown'` fallback chain is gone, and an off-contract event is dropped with + a warning rather than delivered under the literal id `'unknown'`. Delivered + webhook bodies now also carry the event's `id`/`type`/`userId`; the record + itself stays nested under `after` and the envelope keys (`object`, + `recordId`, `action`, `timestamp`) still win. + - `@objectstack/service-knowledge`'s event sync reads the record from `after` + (create/update) and the id from `recordId` (delete) for `data.record.*`. + It previously indexed the envelope itself as if it were the row, and never + resolved an id for deletes. + +- f78dd83: fix(metadata,client): `subscribeMetadata` callbacks receive real `MetadataEvent`s — the producer now fulfils the declared contract (#4602) + + `@objectstack/spec/api`'s `MetadataEvent` declares top-level `id` (uuid, + required), `metadataType`, `name`, `definition?`, `userId?` — and after + #4587's convergence it is the **only** declared contract for realtime + metadata-change events. But the producer (`MetadataManager`) published a raw + `RealtimeEventPayload` envelope with everything nested under `payload` and no + `id`/`userId`, while the client SDK force-cast that envelope into the callback + (`callback(event as any as MetadataEvent)`). Subscribers who wrote + `event.name` / `event.metadataType` — exactly what the types promised — + compiled green and read `undefined` at runtime. + + Producer now fulfils the contract: + + - `MetadataManager.register()` / `unregister()` build a true `MetadataEvent` + (generated uuid `id`, flattened top-level fields, `userId` when the write + declares an actor) and validate it with `MetadataEventSchema.parse` before + publishing. The transport envelope is unchanged (`RealtimeEventPayload`, + with `payload` carrying the complete `MetadataEvent`). + - A `register()` **overwrite now publishes `metadata.{type}.updated`** instead + of a second `.created`, mirroring the existing `added`/`changed` watcher + split. Previously `.updated` was declared with no producer at all. + - `MetadataEventType` is a closed enum: metadata types outside it (e.g. + `translation`) have no declared realtime event, so nothing is published for + them (debug-logged) instead of emitting an event every schema-compliant + consumer must reject. + + Consumer validates instead of casting: + + - `@objectstack/client`'s `subscribeMetadata` (and therefore + `@objectstack/client-react`'s metadata hooks, which delegate to it) unwraps + the envelope and runs `MetadataEventSchema.safeParse` at the boundary. An + off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as MetadataEvent` + double-cast is gone. + + New seam: `MetadataWriteOptions.userId` (`@objectstack/spec/contracts`) lets + write paths that know the acting user carry it into the published event's + `userId`. Existing callers are unaffected — the field is optional and absence + means "no human actor". + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/client/package.json b/packages/client/package.json index 465be696fa..f5e3328756 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 90dbc3cf04..70effd77f3 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,124 @@ # @objectstack/cloud-connection +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 6e1005ed77..79352177c9 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.1", + "version": "17.0.0-rc.2", "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 dacd5f27d8..3e852443f5 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/connector-mcp +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index aaf6e12921..a698b4073d 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.1", + "version": "17.0.0-rc.2", "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 7327016b33..f3fd0c917c 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/connector-openapi +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 50b785a6f4..df8dcdd0b6 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.1", + "version": "17.0.0-rc.2", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index ae7d2cde81..a026614ef7 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/connector-rest +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 718ef5673f..14923d6e68 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.1", + "version": "17.0.0-rc.2", "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 684947906d..cdd230a92f 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/connector-slack +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 446662d738..8f32137007 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.1", + "version": "17.0.0-rc.2", "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 a77614a656..cbb6de3159 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,108 @@ # @objectstack/console +## 17.0.0-rc.2 + +### Minor Changes + +- 072806a: Console (objectui) refreshed to `785b8a5d432c` — the 2026-08-02 objectui batch reaches v17 (#4665). + + Until this pin moves, a merged objectui fix exists only on objectui's `main`: the + release pipeline clones objectui at `.objectui-sha`, so anything newer is simply not + in the artifact the platform ships, and its frontend changeset never reaches the + platform's release history (#3340). Four of the seven PRs merged that day changed + published packages, and one of them is **breaking for authoring** — so that + migration is written out here, in the layer the release notes are compiled from, + rather than left implicit in a SHA. + + ## Breaking for authoring — an action param's picker target is `reference`, and only `reference` (objectui#3203) + + `ActionParam` in `@object-ui/types` no longer declares the nine resolved-side picker + keys: `referenceTo`, `displayField`, `idField`, `descriptionField`, `titleFormat`, + `lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`. + + Migration: + + - **Inline picker target** — rewrite to `reference`: + FROM `{ name: 'account_id', type: 'lookup', referenceTo: 'account' }` + TO `{ name: 'account_id', type: 'lookup', reference: 'account' }` + - **The other eight** — make the param **field-backed** and it inherits the whole + picker group from the object field: `{ field: 'account_id' }`. + + **This removes a compile-time illusion, not a capability.** Those keys were never + storable: `@objectstack/spec`'s `ActionParamSchema` is `.strict()`, its authorable key + list carries `reference` and not `referenceTo`, and its alias table names + `referenceto → reference` by hand — so an authored `referenceTo` has always been a + hard parse rejection on the server. Only `tsc` waved it through, against objectui's + public type, which meant the mistake surfaced at publish time instead of at the + authoring keystroke. `ActionParam` is now derived from the spec schema + (`Omit< z.input< typeof ActionParamSchema >, 'type' >`), so the authoring type and + the parser can no longer disagree about a spelling, and `resolveActionParams()` + additionally names any resolved-only key it meets in a dev-mode warning with the + prescription above — covering params authored in plain JS or JSON, which `tsc` never + sees. + + ## Also author-visible in this batch + + - **An unrecognised dashboard date-filter value is skipped and named, not compared** + (objectui#3196, `@object-ui/core` minor — the other half of #4475). A `date` / + `dateRange` value that is neither a known preset nor a parseable date used to fall + through to "bare string means equality on that day", so a typo + (`defaultValue: 'last_7_dayz'`) reached the backend as `WHERE created_at = $1` and + answered `200 OK` with zero rows — indistinguishable from "this range has no data". + Such a filter is now dropped with a `console.warn` naming the filter, the offending + value and the accepted spellings; the widget's numbers go from 0 to unfiltered. + - **`record:activity` fetches a feed instead of rendering a permanently empty one** + (objectui#3204, `@object-ui/plugin-detail`). The block's eleven declared inputs were + filters over a hard-coded `items={[]}`; the feed now resolves from `items` → a + mounted `DiscussionContext` → a self-fetch of `sys_activity` scoped to the bound + record, and the read-side inputs actually filter. `showSubscriptionToggle` is + labelled `NOT IMPLEMENTED` in its own input description rather than left looking + configurable. + - **A fetching activity feed says "loading", not "No activity recorded"** + (objectui#3210, `@object-ui/plugin-detail` patch). The declared `loading` prop was + destructured into `_loading` and never read, so the panel asserted the record had no + activity for the whole duration of every fetch. + - **`managedBy: 'system'` → `'system-data'` follow-through** (objectui#3214): the + Console now speaks the vocabulary this platform's retirement left standing. + + Full frontend range below. `fix(ci)` / CI-only commits are omitted — they release + nothing and are not in the shipped bundle. + + - fix(fields)!: FieldWidgetComponentProps stops claiming to have every key (#3221) (#3230) + - fix(app-shell): inspectors read and write the expression envelope (#3218) (#3228) + - fix(app-shell): flow simulator evaluates a `{ dialect, source }` edge guard (#3216) (#3217) + - feat(types,core,app-shell)!: follow the `managedBy: 'system'` → `'system-data'` retirement (objectstack#3355) (#3214) + - fix(app-shell): flow branch editor stamps an id on the edges it creates (#3202) (#3215) + - fix(plugin-detail): a fetching activity feed says "loading", not "No activity recorded" (#3205) (#3210) + - feat(plugin-detail): record:activity fetches a feed instead of rendering an empty one (#3165) (#3204) + - fix(types,app-shell)!: `reference` 是 action param 唯一可作者化的 picker 目标 (#3174) (#3203) + - fix(deps): #3184 可合并版 —— focus-scope 栈驱逐竞态补丁,解冲突 + 补丁存废说明 (#3200) + - fix(core): 未知的 date filter 值改为跳过并警告,不再降级成永不命中的等值 (#3151) (#3196) + - fix(types): retarget the objectstack#4171 inverted pins at their real trigger (#3177) (#3194) + - fix(components,grid): a grid's search box searches the list, not the page you can see (#3118) (#3192) + - feat(core): declare the 18 spec-owned action keys ActionDef absorbed silently (#3190) + - fix(app-shell): actually compile `spec-symbol-parity.test.ts`'s type assertions (#3181) (#3187) + - feat(app-shell): wire navigation action items to the console action runtime (framework#4509) (#3180) + - feat(deps)!: upgrade to @objectstack/spec 17.0.0-rc.1 and retire the wait timeout fields (#3101) (#3178) + - fix(studio,timeline,list): 表单设计器解析对象翻译;timeline 认它自己配置的日期字段 (#3134, #3129) (#3175) + - feat(flow-designer)!: the script node authors a function call, and nothing else (framework#4343) (#3170) + - fix(studio): stop offering the retired `action.shortcut` / `action.bulkEnabled` keys (#3154) + - fix(dashboard): date 型 globalFilter 的预设名默认值应提升为区间 (objectstack#4475) (#3150) + - fix(dashboard,report): honor the declared percent scale so a ratio of 1 renders as 100.0% (#3136) (#3140) + - fix(charts): name the slices — pie/donut legends lost their labels to a `type` dimension (#3135) (#3138) + - fix(approvals): record-header Reject fires after one dialog again (#3126) (#3128) + - fix(console): binding-reach 探针少报了自己 6 个块的覆盖面,而且是静默的 (#3149) (#3153) + - fix(flow-designer): the default path is the edge marker, not the branch (#3148) + - fix(plugin-list,plugin-form): 在注册表路径上把 dataSource 接到 list-view / embeddable-form (#3144) (#3147) + - fix(actions): one placement rule for `locations` — declare it or it renders nowhere (#3145) + - fix(app-shell): datasource preview 不再报告读副本数量 (objectstack#4468) (#3143) + - feat(grid): aggregate single-call mode for bulk actions — execution: 'aggregate' (#3141) + - fix(form): `required` is presence, not truthiness — `false` and `0` are values (#3137) + - fix(environment): localize the entitlement dialog + read cloud's nested error envelope (#3130) + - fix(i18n): resolve qualified view ids (#3132) + + objectui range: `7d9734d5e321...785b8a5d432c` + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8c76e4c183..8b0d1d1058 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 67fab921b1..e624dcb738 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,221 @@ # @objectstack/core +## 17.0.0-rc.2 + +### Minor Changes + +- 98877c9: feat(core,platform-objects,spec): the ADR-0119 D2 migration-journal runner — a migration killed mid-run is resumable to completion or compensable to clean, with journal rows proving which (#4617) + + **The gap D1 left open.** ADR-0119 D1 made `engine.transaction()` reachable + through the contract, which is the right answer for multi-write atomicity that + fits in one transaction. Migration-class work does not fit: a million-row + backfill cannot hold one write-lock for its duration, `driver-memory`'s + `beginTransaction` deep-clones the entire database (O(db) per begin), + `ObjectQL.transaction()` binds the **default driver only** so a multi-datasource + migration silently commits part of its work outside it, and a process **killed** + — as distinct from a thrown error — defeats in-process rollback entirely. So the + unit of atomicity is the _chunk_, and durability across chunks is a journal. + + Four consumers had each converged on the same four moves — dry-run preflight, + undo journal, LIFO compensation, re-entrant forward recovery (ADR-0105 D13 + promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, and + D10 master-data distribution #4585). One copy is engineering; four is platform + debt, and the fourth author would have had to rediscover the invariant below + from scratch. + + **New: `runMigrationJournal` (`@objectstack/core`).** Preflight runs every + step's read-only validator before any step writes, so a plan that would fail at + step 3 has not written step 1. Rows are chunked per the `bulk-write.ts` + discipline; each chunk's writes run inside `engine.transaction()`. On failure, + committed chunks are compensated newest-first, each in its own transaction. On + restart, a rediscovered run resumes forward from the first chunk lacking + `chunk_done`, or unwinds, per the plan's `onCrash` policy. Forward and + compensate callbacks receive an `attempt` counter; `attempt > 1` means the prior + outcome is UNKNOWN and the callback must recheck by natural key before + re-writing — the same at-least-once contract `bulk-write.ts` already documents, + reused rather than re-derived. + + **The invariant that carries the design:** `chunk_done(i)` is written **inside** + the chunk's own transaction, so `done ⇔ committed` holds by construction; + `chunk_started(i)` is written autonomously **before** it. That asymmetry is what + gives `started ∧ ¬done` exactly one meaning — _the outcome is unknown_ — which + is the only state a crash can leave and the only state recovery reasons about. + Making both writes symmetric would look tidier and would destroy recovery. + + **New: `sys_migration_journal` (`@objectstack/platform-objects`).** Rows keyed + `(run_id, seq)` under a unique index, so a resumed run that miscomputes its next + sequence fails loudly rather than double-recording an event. Registered + unconditionally alongside `sys_migration` because recovery must be discoverable + with **zero host wiring** — a journal some kernels compose and others do not is + a journal a boot scanner cannot rely on (ADR-0078). Distinct in grain from + `sys_migration`, which holds one durable verdict per named migration; this holds + many rows per _run_. Read-only over the API; writes go through the runner in + system context. + + **The runner refuses rather than degrades**, in four places: the runtime cannot + roll back; any preflight fails; the plan declares `onCrash: 'compensate'` but a + step cannot compensate; or a resume's plan hash disagrees with the journal + (resuming a changed plan would apply chunk boundaries the journal never + described). A compensation failure halts and is journalled — never swallowed — + and the run ends `failed`, not `compensated`, because a database in a state no + clean story covers must not be reported as a tidy rollback. + + **`engineCanRollBack` is now shared.** The two-level probe (engine method AND + default-driver `beginTransaction`) was the same condition written twice — here + and in `batchData`'s atomic gate. It now lives in `@objectstack/core` and + `@objectstack/metadata-protocol` imports it, as a type predicate so callers do + not each re-narrow the optional member by hand. Two copies of "can this runtime + actually roll back?" drift by one clause and leave one caller believing it has + atomicity it does not have. + + Boot reconciliation and `os migrate resume` land separately; `findInterruptedRuns` + is the discovery primitive they will consume, and is exported here. + + **Docs:** ADR-0118 (plugin-reachable transactions) is renumbered **ADR-0119**. + It merged one day after an unrelated ADR-0118 (非用户 actor 的平台契约) and the + earlier merge holds the number; citations of "ADR-0118 D1/D2/D3/D4" written + before 2026-08-03 mean the renumbered record. + +- 071d0dc: feat(runtime,cli,core): boot reconciliation and `os migrate resume` for the migration journal — an interrupted run can no longer go unnoticed (ADR-0119 D2, #4617) + + Completes ADR-0119 D2. The runner and `sys_migration_journal` landed in #4668; this is the discovery channel that makes an interrupted run findable by someone who does not already know it happened. + + **`MigrationRecoveryPlugin` (`@objectstack/runtime`)** — at `kernel:ready`, scans the journal for runs that started and never concluded, and warns per run: how many chunks committed, which have an **unknown** outcome (`chunk_started` with no `chunk_done`), whether a compensation was left half-finished, and the exact command that will act. It also owns the `migration-plans` registry service. + + **`os migrate resume` (`@objectstack/cli`)** — lists interrupted runs (read-only, the default), or acts on one with `--run `, under confirmation. Exits non-zero when a run ends `failed`, so a scripted recovery cannot move on from a migration that needs a human. + + **`MigrationPlanRegistry` (`@objectstack/core`)** — where a resume finds the plan it has to re-run. + + ## Boot discovers, the CLI acts + + This is the design decision, and it is deliberate rather than incidental. + + Resuming is a large, irreversible, potentially hour-long write against production data. Doing that as an unrequested side effect of a process starting is the kind of behaviour an operator finds out about from a graph. It is also not always possible at boot: a resume needs the plan's live callbacks, and the package that owns them may not be loaded in whichever process happened to restart first. + + So boot surfaces the run and names the command; the command acts, under explicit operator intent. ADR-0119 D2's per-plan `onCrash` policy still decides **what** acting means — resume forward from the first chunk lacking `chunk_done`, or unwind what committed — it just does not decide **when**, and "when" is the part a human should own. + + Deferring is safe precisely because of the runner's re-entrancy: `started ∧ ¬done` is durable, so an interrupted run stays exactly as recoverable an hour later as it was at boot. Nothing decays while the operator decides. + + ## Why a plan registry exists at all + + A journal cannot hold a plan. `forward` and `compensate` are functions and `load()` reads the live database, so none of it crosses a process boundary — which is why the journal records the plan **hash**, not the plan. Recovery therefore needs the plan handed back by the code that owns it, and `migration-plans` is that seam: between "the journal knows a run stopped at chunk 7" and "something in this process knows what chunk 7 was supposed to do". + + A run whose plan no loaded package registers is **reported**, never silently skipped — the operator is told which plan id is missing. "Nothing to resume" and "the code that owns this run is not here" are different facts, and only one of them is safe to ignore. + + ## Degradation + + No engine, or no `sys_migration_journal` registered (a lean kernel that never composed platform-objects) → the scan is skipped in **silence**: such a kernel has no interrupted runs to find, and a warning there would train operators to ignore this plugin's output, which is the one thing it cannot afford. A scan that **fails**, by contrast, is reported — "I could not check" and "there is nothing to find" are different answers. + + 11 new tests pin the split (boot writes nothing to the journal), the three states an operator must tell apart (clean / interrupted / half-unwound), and both degradation paths. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index e9837086e8..3931f0cd06 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 3dd6f471dd..c804348a35 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,7 @@ # create-objectstack +## 17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index d61e77909c..95f7311b16 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.1", + "version": "17.0.0-rc.2", "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 26f79db6a2..a702ff57b2 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/formula +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index e78f7d210b..3a220d4a75 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 458cf33134..bb3651602f 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,691 @@ # @objectstack/lint +## 17.0.0-rc.2 + +### Minor Changes + +- 430dcc2: fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352) + + `ActionSchema.body` has always described itself as "Only used when type is + `script`", and its JSDoc went further — "Only meaningful when + `type === 'script'`. When set, the runtime invokes the body inside the sandbox + … and ignores `target`." The runtime read none of it: + `actionBodyRunnerFactory` bound a handler the moment `body` parsed, and + `collectBundleActions` collected any named action. A `type: 'url'` action + carrying a leftover `body` was therefore registered in the action registry and + executed in the sandbox — reachable through + `POST /api/v1/actions/:object/:action` and through + `ql.object(o).execute(name)`, and counted by the governance inventory as a live + handler. + + Declared ≠ enforced, in the shape that is hardest to debug: an author flips + `type` from `script` to `url`, reasonably concludes the body is now dead code, + and it keeps running with nothing anywhere saying so. + + **Behaviour change.** `body` now runs only under `type: 'script'`: + + | Action | Before | After | + | :------------------------------------------------------------- | :-------- | :----------------------------------------------------- | + | `type: 'script'` + `body` | body runs | unchanged — body runs | + | `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) | + | `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged | + + Only an action that **explicitly** declares a non-`script` type _and_ carries a + `body` changes behaviour. An omitted `type` still means `script`, because the + collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy + `manifest.actions[]` never passes through `ActionSchema`, so the schema's own + default has to be applied at the gate rather than assumed to have been applied + already. + + **FROM → TO.** If you have an action whose body you want to keep running, set + `type: 'script'` and move the navigation/dispatch target elsewhere; if you want + the target behaviour, delete the now-inert `body`: + + ```diff + { + name: 'open_portal', + - type: 'url', + + type: 'script', + target: '/portal', + body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] }, + } + ``` + + The refusal is **not** silent — silence would only relocate the invisibility the + issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its + declared `type`, and both fixes. + + Authoring-time rejection of the same contradiction already shipped in #4438 + (`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains + reachable here is data at rest published before that gate existed, plus bundles + that never parsed. This release closes that half. New tests also pin that the + **publish gate resolves to the rejecting schema** — through + `getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of + either registration cannot silently reopen the hole while the schema's own unit + tests stay green. + + `@objectstack/lint`'s `validate-action-body-writes` filters by `type` again. + #4344 deliberately made that rule type-blind on the grounds that "the runtime + binds a handler from `action.body` alone … checking what executes beats checking + what the schema says should" — true then, and the comment predicted its own + revision. Execution and declaration are the same set again, so a non-`script` + body no longer produces write-set advice about writes that provably never + happen; the publish gate names that metadata's real defect (`type`) with its own + prescription. + + `collectBundleActions` stays deliberately type-blind: it feeds governance + surfaces that must enumerate every declared action, bound or not, and the other + bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never + walks it. The gate lives at the single point where a `body` becomes an + executable handler, so there is no second copy of the rule to drift. + +- 0800433: Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`). + + New advisory rule `action-no-placement`: an action that declares no + `locations` and that no list view places by name renders on **no** surface — + it parses, publishes, and appears in Setup, while no user can ever click it. + ADR-0078 names this shape in its opening paragraph and Phase 3 asks for + exactly this rule; the shared completeness predicate it envisioned was never + built, so this lands standalone, one verified shape at a time. + + What made it verifiable now: objectui#3142 collapsed four disagreeing + renderers onto one placement predicate. Before that, `action:bar` and the + record header rendered an _undeclared_ action anyway, so the shape only looked + inert on paper. As of objectui 17.1 it is measurably inert. + + Two things are deliberately **not** flagged: + + - **`locations: []`** — the documented headless action (callable over REST / + MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a + headless declaration is the only legal way to expose one. The rule therefore + distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key + absent) and only reports the latter. + - **Actions a view places by name** — `bulkActions`, `bulkActionDefs` + (including `execution: 'aggregate'` defs, whose whole point is an action with + no single-record home) and `rowActions`, across all three list-view tiers: + `views[i].list`, `views[i].listViews.` and the object-embedded + `objects[i].listViews.`. + + Advisory, never fatal — a view in another installed package may be the one + placing the action, the same reason `validateSemanticRoles` and + `lintLivenessProperties` warn rather than gate. + + Also: the action form schema in `@objectstack/metadata-protocol` no longer + declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()` + tombstones in spec 17, and this schema is what the Studio designer renders its + fallback form from — so advertising them handed authors two inputs that could + only ever produce an unsaveable draft (objectui#3145 removed the matching + dedicated controls). And `content/docs/ui/actions.mdx` now says which surface + is the exception to location filtering, instead of a blanket claim its own + showcase contradicted. + +- 85a966f: Nav targets that are not object names (`page` / `report` / `dashboard`) are now checked at author time — closing a hole _inside_ an existing check. + + `defineStack`'s `validateCrossReferences` already validates these three. But each arm is gated on the collection being non-empty: + + ```ts + if (nav.type === 'page' && typeof nav.pageName === 'string' + && pageNames.size > 0 && !pageNames.has(nav.pageName)) { … } + ``` + + So a stack that declares **no `pages` at all** has its page-nav check silently switched off, and `{ type: 'page', pageName: 'anything' }` sails through. That is exactly the state a stack is in when the target was never written — the most likely way to reach this bug, not the least. + + Note the asymmetry the guard creates. The `object` arm of the same block has no size gate: it errors unless the item carries `requiresObject`, an **explicit** opt-in to "another package provides this". Objects have to say so out loud; pages, reports and dashboards got an implicit exemption that depends on an unrelated property of the stack. + + `validateNavTargetRefs` joins `REFERENCE_INTEGRITY_RULES` (16 → 17), so it runs on `validate`, `lint` and `compile` with no CLI rewiring. It reports **warning**, not error, and that ceiling is deliberate: `validate-object-references` can say ERROR for an unresolved _object_ because it resolves against the curated `PLATFORM_PROVIDED_OBJECT_NAMES` registry and knows which cross-package names are real. No such registry exists for pages, reports or dashboards, so "unresolved" cannot honestly be distinguished from "provided by a package we cannot see". Fixing the guard by tightening the parse-time throw was the other option and was rejected: a throw has no escape hatch for a legitimately cross-package page, and ADR-0072 D1's rule is that one dead finding costs more than a missed one. When `defineStack`'s check _is_ live it still hard-fails first; this rule is what speaks when that check has switched itself off, and it says so in the message. + + **Three nav types are deliberately NOT covered, each verified rather than assumed:** + + - **`action`** — already owned by `validate-action-name-refs`, which walks app navigation explicitly. Adding it here would double-report. + - **`component`** — a verified NON-rule. An unregistered `componentRef` does _not_ fail silently: `ComponentNavView` renders a named diagnostic ("Component not registered … Ensure the plugin that provides this surface is installed and has called `registerAppComponent()`"), and the registry exists precisely so plugin-provided surfaces may legitimately be absent. Flagging it would break valid plugin nav and prescribe a fix for something already reported better at runtime. + - **`url`** — external by definition. + + Both NON-rules are pinned by tests, so "completing" the module by adding them fails there first. + + **Scope honesty:** all 35 authored nav page/report/dashboard targets in this repo resolve, so this closes a latent hole rather than a shipped bug. The rule was proven to go red and then green through the real `validateReferenceIntegrity` entry point on a known-bad stack, not only in unit tests — a green check that has never been made to fail is the recurring defect this campaign keeps finding in its own instruments. + +- a7163ea: The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface. + + This closes the hole _between_ the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only _live_ properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green. + + **Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes. + + **One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed _only_ in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it. + + **Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing: + + | rule | the silent skip | severity | + | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------- | + | `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | + | `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error | + | `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | + | `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | + | `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning | + | `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning | + + **The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is _not_ flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass. + + **It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that _is_ complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets." + + Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time. + +- e6e9379: ADR-0078 Phase 3: a webhook with no `triggers` now fails at author time — and the Tier-B candidate list is corrected to what verification actually supports. + + **The rule.** `webhook/without-triggers`, error severity, in the shared `@objectstack/spec/kernel` predicate alongside the Phase 1 rules, walked by `@objectstack/lint`'s `validate-functional-completeness` over `stack.webhooks` in both collection spellings. A webhook that declares no trigger materializes into `sys_webhook`, renders in Setup looking armed, and delivers nothing. + + **Why it needed two sources, and why the first one argued against it.** The runtime skip site reads: + + ``` + if (triggers.size === 0) { + // No dispatchable triggers (or a manual-only webhook with none) — + // skip auto-enqueue. + return null; + ``` + + That parenthetical _blesses_ the empty case as a deliberate mode — structurally identical to the `multiselect`-without-options NON-rule, where `record-validator.ts`'s `// free-form (tags without options)` is exactly why we do not flag it. On that evidence alone this candidate stays unenforced. + + The mode it names does not exist. `webhook.zod.ts`'s #3196 note records that the `api` (manual/programmatic fire) trigger was _removed_ because "no manual fire path exists — the only webhook HTTP surface re-queues already-failed deliveries". There is no way to fire a webhook the auto-enqueuer dropped. Inert on every path, so: `error`. + + > **The generalization, now written into the module and pinned by a test:** a runtime comment records what its author believed, and beliefs go stale when a sibling feature is deleted. A blessing has to be corroborated by something showing the blessed mode is still _reachable_ — otherwise it is a comment about a mode that no longer exists. The test asserts the finding carries both citations, so nobody demotes this rule on the strength of the comment alone. + + `triggers: []` is flagged identically to an omitted `triggers`. Unlike an action's `locations: []` — the documented headless spelling — an empty array here carries no "I meant it" signal, because turning a webhook off has its own key (`isActive`). The repo's one real webhook (`showcase_task_changed`) confirms it: shipped inactive via `isActive: false`, with a full trigger list. + + **The corrected Tier-B disposition.** Phase 3 was scoped from the 2026-06 audit's Tier-A/B catalog. Verifying each candidate before writing it — the discipline that caught four false prescriptions in #4001 — found most of the list already closed or misfiled: + + | candidate | disposition | + | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | + | A2 action without `locations` | **already shipped** — `validate-action-locations.ts`, which already exempts the documented `locations: []` | + | B approval empty/unresolvable approvers | **already shipped** — `validate-approval-approvers.ts` | + | B select/multiselect without options | shipped in Phase 1 | + | B write-side referential integrity | **not an authoring-lint item** — a runtime gap; no metadata omission to detect | + | B `unique:true` no-op on memory driver | **not an authoring-lint item** — a driver gap | + | B composite/repeater sub-field constraints | **not an authoring-lint item** — a runtime gap | + | B nav targets of type page/report/url/component/action | **genuine gap, different module** — the key is present but dangling, which is reference resolvability (ADR-0072), not completeness (ADR-0078) | + | B dataset with zero measures | **unverified — not shipped.** No runtime consumer in this repo; the dataset compiler lives elsewhere | + | B webhook without triggers | ✅ **this change** | + | B schedule trigger with invalid cron | **unverified — not shipped.** `normalizeSchedule` accepts any non-empty string, but the scheduler's behaviour on an invalid one was not traced | + + Two candidates are deliberately left unshipped rather than written on the audit's stated confidence, and one is left for the module that actually owns it. The audit's own lesson stands: it produces _candidates_, not confirmed bugs — the scariest one collapsed on a three-file read. + + Tracked in #4544. + +- 459f925: feat(lint): `has(x)` 不是 null 守卫 —— 发布期直接拒绝未守卫的可空比较 (#4763) + + CEL 的 `has(x)` 问的是**键是否存在**。自 #4649 起,谓词读到的记录对对象声明的每个 + 字段都是**全量**的:一个声明了却存 `NULL` 的列同样"存在",所以 + `has(record.end_date)` 对声明字段恒为 `true`,什么也没告诉作者。于是这个读起来 + 像守卫的写法根本不是守卫: + + ```text + has(record.start_date) && has(record.end_date) && record.end_date < record.start_date + ``` + + 它会走到 `null < null`,CEL 没有对应重载,整个谓词中断。#4761 之前中断被吞掉 + (规则跳过,一条 WARN),也就是说**这一形状的规则在任何含 null 值的行上从未生效 + 过**——它写在元数据里、读起来完全正确、却什么都没有强制执行。#4761 把运行时改成 + fail-closed 之后,当场就在我们自己的两个示例对象里抓到了它。 + + 运行时拒绝是兜底,不是该学到这件事的地方:作者会在真实数据(很可能是生产数据) + 上收到一个 400,离写下规则可能已经过去几个月。而这个错误**仅凭元数据就可判定** + ——谓词的 AST 加上对象声明的字段类型,就足以判断某个操作数是否可能为 null。按 + AGENTS.md PD #12(在创作期拒绝,不要在消费端容忍),它属于发布闸门。 + + **新增闸门(error,直接拒绝,没有降级开关)。** `os build` / `os validate` / + `os lint` 与运行时发布闸门共用的 `validateStackExpressions` 现在会拒绝这样的谓词: + 对**声明为可空**的字段(没有 `required: true`、没有 `defaultValue`、没有默认选项、 + 不是 autonumber)应用**排序**(`< <= > >=`)或**算术**(`+ - * / %`,含一元 `-`) + 运算符,而该操作数没有被同一布尔分支内支配它的 `!= null` / `== null` / `!isBlank()` + 显式判空所守卫。`has(x)` **刻意不**计入守卫——这正是本规则存在的理由。错误信息点名 + 规则、操作数与修法,收尾句逐字取自 `rule-validator.ts` 的 `unevaluableRuleError`, + 两道闸门措辞完全一致。 + + 覆盖面(有意划定,而不是含糊地覆盖一半):对象**校验规则**(含 `conditional` 规则 + `then` / `otherwise` 里嵌套的谓词)与**生命周期 hook 的 `condition`** ——即真正由 CEL + 在全量记录上求值、会 fail-closed 的两类面。共享规则条件(下推成 SQL 过滤,`NULL > x` + 是三值逻辑,不会 fault)、flow 的扁平作用域条件(裸标识符可能是 flow 变量)与 + `Field.formula`(有自己的 #3306 `guard ? value : null` 处理)不在此列。 + + 对**未声明**键的 `has()` 完全不受影响——那才是它的正当用途:区分"这次 PATCH 里 + 根本没提到这个键"与"显式写了 null"。示例应用无需改动即通过新闸门。 + +- 8e53e5d: feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830) + + 视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而 + REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定 + 该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400 + (`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查 + 字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。 + + 新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级 + narrowing(对象内建 `listViews`、`defineView` 的 `list`/`listViews`、react 页面的 + ``)按**运行时同一个函数**(`resolveSearchFieldResolution`, + 非复制的类型清单,杜绝再度漂移)判定 declared = enforced: + + - 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等 + auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本 + 对象 text/formula 字段」的处方; + - 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、 + 不能放宽,ADR-0061); + - 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按 + 存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据 + (ADR-0072 D1); + - 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报 + 不可误报)。 + + 内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包 + barrel)签名有变:索引值从 `Set | null` 变为 `ObjectSearchTarget | null`,并新增 + 可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。 + +- ebb209c: fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413) + + The react-tier contract published `objectName` / `recordId` on + ``, ``, `` and + ``, and no renderer read either prop. All ten `record:*` renderers + take their record from `useRecordContext()`, which only the record route + (`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever + mount; the `kind:'react'` page renderer wraps the page in a + `SchemaRendererProvider` alone. So the blocks rendered their "bind a record to + preview" placeholder — or, for `record:related_list` (the one that does read + `schema.objectName`), refused to fetch because the parent id never arrived. A + page authored exactly to contract came back EMPTY with nothing reported + anywhere, including by `os validate`, which resolved those props' field names + against the object they named: lint standing guard over a binding that never + ran. + + Withdrawn rather than implemented. The contract was not merely unimplemented, + it was the wrong SHAPE: per-block bindings describe four independent fetches of + one record, which is exactly the coupling the shared record context exists to + prevent (`record:details` drops the fields a mounted `record:highlights` + registered; one inline-edit save bar commits them all under a single + `ifMatch`). Honoring the props would have fossilized that (Prime Directive + #12). The naming of that primitive — a record SCOPE an author wraps around the + family, one fetch, shared context — is the open design question, filed as #4444. + + `@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the + ledger for why, plus the working replacement per type. The family is derived + from `ComponentPropsMap`, so a record component added later is gated the day it + lands — including the six that were never in the contract but are just as + reachable through the registry-built react scope. + + `@objectstack/lint` gains `react-block-needs-record-context` (error), which + rejects them on a react page by tag and through `` + alike, quoting the block that does work: `', '=', +parentId]}>` for a related list, `` for a + field panel. A locally-declared component of the same name shadows the injected + scope and is left alone. + +- 4b945fc: Author-time rules now gate the RUNTIME metadata write path, not just the CLI (#4463) + + The 26 author-time rules `os validate` / `os build` / `os lint` share (#4409) ran on + those three commands and nowhere else. Every runtime metadata write — Studio's + designer, REST `/meta` item CRUD, an MCP/AI agent authoring a flow — reaches + `saveMetaItem`, which did a per-type Zod `safeParse` and stopped. For a tenant that + was not the weakest of four doors, it was the **only** door: a `sys_metadata` + overlay row is not in the CLI's config file, so there was no command they could run + instead. An approval flow whose `expression` approver is broken CEL + (`record.owner ==`) is Zod-valid, so it saved, registered, and failed at the node's + entry the first time it fired — the exact body `os lint` had rejected since #4409. + + **One shared core, one runtime gate.** + + - The rule registry moved from `packages/cli` into `@objectstack/lint` + (`AUTHORING_RULES`), and the CLI now calls it there. Five rule modules moved with + it (`lintFlowPatterns`, `lintLivenessProperties`, `lintAutonumberFormats`, + `lintViewRefs`, `data-model-rules`), unchanged. There is one table; a second one + cannot be introduced without failing `authoring-rule-wiring.test.ts`. + - New kernel-safe subpath export **`@objectstack/lint/runtime`** — the entry the + metadata write path imports. Running the gate loads neither `typescript` nor + `sucrase`, pinned by a new `runtime-lazy-deps.test.ts` alongside the existing + `lazy-deps.test.ts`, which is unchanged. + - Each registry entry now declares `surfaces` (`cli` / `runtime-publish`) plus + either the metadata `runtimeTypes` it judges or a written `surfaceReason`. The + ratchet fails an entry that answers neither. + + **Behaviour** + + - A `state: 'active'` `saveMetaItem` — and the draft→active promotion in + `publishMetaItem` — of a **flow** runs the flow / approval / expression / + reference rule families. A gating finding is refused with **422 + `INVALID_METADATA`**, in the same structured envelope the Zod failure already + used, with `rule` / `path` / `where` / `message` / `hint` per issue. + - **Draft saves are never gated** — a draft is allowed to be half-finished and + cannot execute. + - Only the write is judged: the rules run twice (context with and without the + submitted item) and only findings the item _added_ can refuse it, so a + pre-existing violation in a stored row never blocks an unrelated save. Stored + rows keep being read. + - Escape hatch **`OS_ALLOW_UNLINTED_METADATA_WRITES=1`** turns the refusal into a + loud log for a migration window. Unset it once the metadata is fixed — the + runtime executes what it published. + + Only `flow` writes are gated in this pass; every other metadata type carries a + recorded reason in the registry. + +- 97faca3: feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457) + + A selection-bar bulk action was declared as + `z.array(z.record(z.string(), z.any()))` — **no shape at all**. The real + contract lived in objectui's `BulkActionDef` interface and in the executor that + reads it, so every authoring mistake landed as a silent runtime downgrade: + `opeartion` parsed and the executor hit `Unknown operation: undefined` per row; + `excution: 'aggregate'` parsed and the def stayed per-record, so the endpoint + written for ONE `_selectedIds` call got N calls instead — the exact defect + objectui#3139 was filed to make expressible. That is ADR-0018's "second + vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks) + crossed with ADR-0078's silently-inert metadata. + + `ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema` + got in #3746/#4001: a **strict** def whose unknown-key error names the offending + key and the canonical spelling. Beyond spelling, it refuses the combinations the + executor never reads — `patch` outside an `update`, `execution` outside a + `custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a + hand-written `actionDef`, which is attached by the renderer when it resolves the + def's `name` and which authored by hand would smuggle an action definition past + the action registry. + + **One shape that parsed before is now rejected**: `operation: 'custom'` without + `execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly + one authored shape (the aggregate one); every other custom def falls to + `Promise.resolve()` per row — a button that reports success for every selected + record and does nothing. The error names both legal forms: `bulkActions: +['']` for per-record (promoted with the action's own label, params and + `visible`), `execution: 'aggregate'` for one call over the whole selection. + + Two things are deliberately left open: + + - **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a + `[key: string]: unknown` catch-all — widget config (min/max/step/format) + forwarded to the field renderer as-is. Locking it down would reject valid + config, so declared keys are typed and the rest rides through, the same call + `dashboard.zod.ts` makes for a widget's `config`. + - **The bulk-param / action-param spelling divergence** (`help`/`helpText`, + `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which + `ActionParamSchema` has no counterpart for). objectui already owns a converter + for the promoted direction; converging the authored direction is a cross-repo + change with its own migration. Typing them as they are is what makes the + divergence visible rather than undocumented — the prerequisite for closing it. + + `label` and the param/option labels are `z.string()`, not `I18nLabelSchema`: + an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map + on this path) and the bar renders `def.label` as a React child, so blessing the + map form would trade a parse error for a blank screen. Localize by declaring a + real action and naming it in `bulkActions` — that path runs through the i18n + resolver. + + **Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an + `execution: 'aggregate'` entry is a name reference (it is what + `resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id + and resolving it would be nonsense. The walk also reaches an **object's own + `listViews`** for the first time — an object has no top-level `list`, so that + tier had simply never been visited while the view-level ones were covered. And + the hint no longer tells a bulk-surface author to add a `locations` entry: the + selection bar is the one surface that does not filter on it, so naming the + action there is the whole placement. + + Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`. + +### Patch Changes + +- f3141d8: fix(spec): a node that publishes no descriptor configSchema can now own an expression-ledger entry (#4439) + + `FLOW_NODE_EXPRESSION_PATHS` is the #4027 ledger that tells `registerFlow` and + `objectstack validate` which config keys hold expressions, and in which dialect. + Its ratchet (`config-expression-ledger.test.ts`) derives what it expects from + descriptor `configSchema` `xExpression` markers, and fails in **both** + directions — an undeclared marker, or a ledger entry nothing declares. + + `decision` / `script` / `subflow` publish **no** descriptor `configSchema` on + purpose: a published partial schema would drop the editors their hand-written + Studio forms need (the #4210 incident), so their contract lives in + `schemaless-node-config.zod.ts`. Those two rules compose into a hole — an + expression slot on a schemaless node is structurally unreachable by the ratchet, + and because the reverse direction rejects unclaimed entries, it cannot be + entered by hand either. + + `decision.conditions[].expression` sat in that hole. Its own schema says + _"Bare CEL predicate deciding this branch"_ and its own comment names `{…}` as + the #1491 trap, and no validator walked it — so `{lead_record.status} == +'converted'` passed `tsc`, passed `objectstack validate`, passed registration. + #4414 made that fail loudly at run time; this makes it fail at build time, + which is the delay #4027 exists to remove. + + ## The fix + + The ratchet now reads **both** declaration channels: + + - **descriptor `configSchema`** — unchanged, enumerated from the live registry; + - **`schemaless-node-config.zod.ts`** — the marker rides + `.meta({ xExpression })` through `z.toJSONSchema`, the same channel + `loop.collection` has used since objectui#2670. + + Spec hands the second channel over as JSON Schema + (`getSchemalessNodeConfigJsonSchemas()`, memoized, `input` mode — the shape a + descriptor's `configSchema` already is), so the ratchet walks both with the + _same_ function. No second notion of "a declared expression property", which is + the duplication a ledger exists to remove, and no `zod` dependency added to + `service-automation`. Each channel is separately asserted non-empty, so a broken + derivation on one side cannot hide behind the other's results. + + `SCHEMALESS_NODE_CONFIG_SCHEMAS` is also exported for anything else that needs + to reason about all node config contracts. Additive — objectui's + `flow-node-config` reconciliation imports each schema by name and is unaffected. + + ## The sweep + + The other schemaless slots were checked and deliberately carry no marker: + `script.template` is a template **id**, not a body; `script.inputs` / + `script.variables` / `subflow.input` are values that interpolate `{token}` — + text-with-holes, the shape essentially every node config string has, already + covered generically by `validate-flow-template-paths` and the CLI flow linter. + A `flow-template` ledger entry means something narrower: a _reference that must + resolve to a value_, like `loop.collection`. So `decision.conditions[] +.expression` is the only genuinely declared expression slot on the class — now + recorded in the ledger's header so it is not re-derived. + + ## Docs corrected + + The flows guide taught the **wrong dialect** for decision predicates in three + places (`'{order_amount} > 10000'`), plus a "braces missing in a decision + expression" warning that inverted after #4414 — and `FlowNodeSchema`'s own + `@example` did the same. All corrected to bare CEL, with the history stated so + an author with a braced predicate knows what changed and why their build now + fails. The dialect table drops from three dialects to two: predicates never take + braces, values always do. + + Verified: 13 new/updated tests across the ratchet, the engine's registration + pass and `@objectstack/lint` (including the exact app-crm predicate rejected at + both `registerFlow` and `objectstack validate`); `pnpm build`, `pnpm typecheck` + (122 tasks), `pnpm lint` and `check:docs` clean. + +- fd3013a: feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343) + + A `script` node had four ways to name what it ran and only one of them ran anything. + Protocol 17 keeps that one and retires the rest. + + - **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a + line, reported success, and delivered nothing — under any configuration, installed + messaging service or not. Every bundled example used one; none of them ever sent + anything. + - **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed + a message no channel sent. (The examples did not even reach them: they passed the + payload in `inputs`, which the built-in branch never read.) + - **inline `config.script`** was recognized and **never executed** — the built-in runtime + has no server-side JS sandbox, so the node warned and completed as a no-op. + - **any other `actionType`** was shorthand for a registered-function name — a second + spelling of `config.function` — and `'invoke_function'` was a marker that named nothing + on its own. + + What remains is what worked: `config.function` (now **required**) names a registered + function, `config.inputs` feeds it, `config.outputVariable` binds its return value. + + **The replacements are three different mechanisms, not one rename.** + + | Retired | Use instead | + | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | + | `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel | + | `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | + | `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` | + + **Execute-time parse.** `script` and `subflow` now run their config through the contract + before executing, the seam #4277 gave the flat builtins — a violation refuses the node as + a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join + that seam while its legal key set depended on `actionType`: a flat parse would either + reject valid shapes or wave everything through. Converging the node is what made the + contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its + message is now `subflow 'n1': config does not satisfy the subflow contract — +config.flowName: …`. `decision` deliberately stays export-only: its one key is optional, + so a parse would check nothing. + + **Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these + keys in TypeScript is a compile error carrying the same prescription. A shorthand + `actionType` **converts into `function`** — that is what it named — unless `function` is + already set, in which case it was dead metadata the executor never reached. The other four + keys are dropped outright: nothing read them, so there is no value to preserve, and + rebuilding the intent is an authoring decision (the table above) rather than something a + mechanical rewrite can guess. + + The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired + for _misdescribing themselves_ rather than for being renamed: absorbing + `actionType: 'email'` silently would let an author keep believing the flow sends mail. The + one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) — + a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node + arrives stripped of the keys nothing read and then **refuses for naming no callable**, + where it used to log a line and report success. That flip is the behavior change to expect. + + **A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a + **lowered handler ref** (a non-empty string), the form `objectstack build` produces: the + CLI lowers every inline callable to a serialisable ref _before_ the stack is parsed (it + must — `z.function()` wraps callables and would break the ref mapping), so a built + manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was + that `defineStack({ functions })` — a documented, first-class mechanism — could not + survive a build at all. Nothing had noticed because no bundled example used it; #4343 + turns that from latent into blocking, since `config.function` becomes the only thing a + `script` node can run. `Hook.handler` already declared exactly this pair (`z.union([ +z.string(), ])`, "string, post-build / inline function, pre-build"), so this + brings `functions` onto the platform's established shape rather than inventing one. A + string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the + real functions ride in the sibling ESM module the build emits, merged by name — so + hand-authoring one registers nothing and fails loudly at execute ("no function named '…' + is registered"), never silently. + + Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`, + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed + (they described the dispatch set that no longer exists); `os validate` names a retired key + and its replacement instead of reporting a generic missing callable; and the `#3796` + alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer + describes an end state protocol 17 can reach — the rename itself is untouched. No liveness + ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is + `z.record(z.unknown())`, so these keys were never governed by one. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + - @objectstack/sdui-parser@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index c4da5fc478..191db40e0a 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,8 +1,8 @@ { "name": "@objectstack/lint", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "license": "Apache-2.0", - "description": "Static, build-time validation for an ObjectStack metadata graph \u2014 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.", + "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", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 005c556dff..8c05e9e1ae 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/plugin-mcp-server +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 17f365b494..d6b1f2d611 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 48332af280..6716031e2a 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,232 @@ # @objectstack/metadata-core +## 17.0.0-rc.2 + +### Major Changes + +- 65f184b: fix(metadata)!: `sys_metadata_history.recorded_by` stores NULL, not the sentinel string `'system'` (#4556) + + `recorded_by` is declared `Field.lookup('sys_user', { readonly: true })` — a + foreign key. The write path filled it with `actor ?? 'system'`, so every + metadata write without a caller actor (boot sync, migration, an internal call) + stored the **string** `'system'` in a column whose declared type says "the id + of a `sys_user` row". No such row exists, and `SystemUserId.SYSTEM` + (`'usr_system'`) is not auto-provisioned on the current runtime either, so the + value resolved to nothing under any reading. Any consumer that read the field + by its declaration — `expand`, an owner column in a report, an audit timeline + showing "who changed this" — got an id that could not be dereferenced. + + It had already cost twice. #4441 had to exempt every `readonly` field from the + write-path referential-integrity check, because otherwise ordinary metadata + authoring (package create / publish / clone) was rejected. #4551's + dangling-reference audit had to skip the same set for the same reason. The + field ended up the platform's only reference column that is neither enforced + nor audited. + + **The fix is on the write path, not the declaration.** `recorded_by` stays a + `lookup('sys_user')`; an actor-less write now stores `NULL`, and `NULL` means + "system-initiated (boot sync, migration, scheduled job)" — the standard + expression of "no link", and already what this column's `set_null` delete + behaviour means. No magic system-user account (a row that can never sign in yet + holds an identity is a new security surface), and no `actor_kind` companion + column. + + **Breaking — the repository contract is now explicitly nullable.** + + | Surface | Before | After | + | :---------------------------------------- | :------- | :------------------------------------ | + | `PutOptions.actor`, `DeleteOptions.actor` | `string` | `string \| null` (still **required**) | + | `MetadataEvent.actor` | `string` | `string \| null` | + | `MetadataItem.authoredBy` | `string` | `string \| null` | + + `actor` stays required rather than becoming optional on purpose: every call + site must state which of the two it is, so a forgotten actor cannot silently + become a fake foreign key. Migrating a caller: + + - **Writers** — passing a real identity: unchanged. Passing `'system'`, `''`, + or a label to satisfy the type: pass `null` instead. + - **Readers** — `event.actor` and `item.authoredBy` can be `null`. Handle it at + the point of display (`actor ?? 'System'` in a UI string is fine — the fix is + that the _stored_ value no longer lies, not that no label may ever be shown). + + Two read paths also stopped inventing a value: `SysMetadataRepository.history()` + and `getByHash()` rendered an absent actor as the string `'unknown'`, which is + indistinguishable from a real user id to anything that resolves the field. They + now surface `null`. + + **Existing rows: `os migrate recorded-by`.** The stored `'system'` values are + rewritten to `NULL` by a new command, which runs the conversion through the + ADR-0119 D2 migration journal (chunk-atomic, resumable via `os migrate resume`). + It is a dry run by default and safe to re-run — it selects only rows still + holding the sentinel, so a second `--apply` converts nothing. + + The rewrite is **semantically equivalent, not a reinterpretation**: this column + has only ever held that one sentinel, written by exactly one expression + (`actor ?? 'system'`), and both spellings mean "no actor" — only `NULL` is + expressible in the declared type. + + Deliberately unchanged: `sys_metadata_audit.actor` is a `text` column whose + declaration already says "user id, system id, or `'system'`", so its `'system'` + default is honest and stays. The #4441 `readonly` narrowing and the #4551 audit + skip also stay — see the PR for why they are still correct. + +- ce92674: feat(spec)!: retire the standalone `validation` metadata kind (#4509, ADR-0088) + + A validation rule authored as its own artifact bound to nothing and gated no + write. `ValidationRuleSchema` carries **no object-binding key** — no `object`, + no `objectName` — and all six variants are `strictObject`, so an author could + not supply one either. No merge step existed. The only code that expected such a + key was a reference-tracker row scanning a field the schema would have stripped. + Meanwhile the engine evaluates exactly one shape: the object's own + `validations[]` array, on insert and on every matched update row. + + So a rule created through the standalone door — a `*.validation.ts` file, or + Studio's Validations list — parsed, saved, reported success, and intercepted + nothing. Including a `state_machine` rule, which ADR-0020 routes through this + same vocabulary: an author could believe they had locked down record state + transitions and have changed nothing at all. + + Under ADR-0088 the kind fails the admission test on its first clause: a rule has + no independent lifecycle, because it only means something against an object. And + unlike the sibling disconnects closed in this batch, it could not be bridged into + one — the shape has nowhere to name its object. + + **The rule vocabulary is untouched.** `ValidationRuleSchema` and all six + variants are unchanged and fully live; the engine's evaluation path is not + modified by this change. It is the _kind_ that was inert, not the schema. The + liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS` + override (alongside `webhook` and `query`), because an ungoverned live schema is + exactly how the next drift would hide. + + **Migration.** Move the rule into the owning object's `validations:` array — the + rule body is identical, same schema, same six variants: + + ```ts + // before — a standalone *.validation.ts, which never ran + export default defineValidation({ name: 'amount_positive', type: 'script', … }) + + // after — on the object, where rules are evaluated + ObjectSchema.create({ + name: 'invoice', + validations: [{ name: 'amount_positive', type: 'script', … }], + }) + ``` + + Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml` + patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum + member, the schema-map entry, the create seed, Studio's Validations nav item and + its hand-crafted form, and the dangling reference-tracker row. Standalone rows + already in `sys_metadata` are left alone — they were never evaluated, so nothing + changes behaviorally. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index de27f6758e..e1ee72fa93 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.1", + "version": "17.0.0-rc.2", "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 9ba6f066f0..93388e8ba0 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/metadata-fs +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [65f184b] +- Updated dependencies [ce92674] + - @objectstack/metadata-core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 3074e1b85e..20f27c169a 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.1", + "version": "17.0.0-rc.2", "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 630dae5b5f..6e638c734c 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,1126 @@ # @objectstack/metadata-protocol +## 17.0.0-rc.2 + +### Major Changes + +- ac37fc6: fix(metadata-protocol)!: batch per-row results now deliver the declared `BatchOperationResultSchema` shape (#4793) + + **Breaking wire change** on the per-row `results` entries of the three + bulk-write endpoints — `POST /data/:object/batch`, `/updateMany`, + `/deleteMany`. The rows had drifted from the schema that declares them: + `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` + type and the reference docs all said `errors: ApiError[]` / `data` / `index`, + while the wire carried `error: string` / `record` and never sent `index`. A + TypeScript consumer written against the published type compiled, validated, + and read `undefined` at runtime. The wire now delivers exactly what is + declared (a conformance pin parses every emitted row against the schema, so + the two cannot silently fork again). + + **FROM → TO, per row:** + + | Before (legacy wire) | After (declared schema) | Your fix | + | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- | + | `row.error` (string) | `row.errors` (`ApiError[]`) | read `row.errors?.[0]?.message`; branch on `row.errors?.[0]?.code` | + | `row.record` | `row.data` | rename the read | + | — (never sent) | `row.index` (number) | new — the row's position in the request array; use it to correlate failure rows that carry no `id` | + | `row.droppedFields` | `row.droppedFields` | unchanged | + + **Rollback marking is structured now.** The `ROLLED_BACK:` / + `NOT_ATTEMPTED:` message-string prefixes that #4620 introduced (see the + `many-data-atomic-real-or-refused` changeset — its description of those + markers is superseded by this entry) are promoted to first-class + `ApiError.code` values, registered in the spec's ERROR_CODE_LEDGER: + + - `errors[0].code === 'ROLLED_BACK'` — the row was written, then undone by the + atomic batch rollback; `message` carries the causal row's index and error. + - `errors[0].code === 'NOT_ATTEMPTED'` — the row never ran; an earlier row's + failure aborted the batch. + - the causal row keeps its own error code (e.g. `RECORD_NOT_FOUND`, + `VALIDATION_FAILED`; an unclassified engine throw maps to `INTERNAL_ERROR`, + with `httpStatus` mirrored when the error carried one). + + Branch on the code — do **not** regex message prefixes; the prefixes are gone. + + **Who is affected:** only readers of the _legacy_ keys — which were never in + the schema or the SDK types, so they were reachable only via `as any` or bare + JS. Code written against `BatchOperationResult` (the published contract) needed + this change to start working and needs no migration. There is no + dual-emission or compatibility fallback: this is a hard cut inside the v17 + major window, and the old keys simply no longer exist on the wire. + +- 65f184b: fix(metadata)!: `sys_metadata_history.recorded_by` stores NULL, not the sentinel string `'system'` (#4556) + + `recorded_by` is declared `Field.lookup('sys_user', { readonly: true })` — a + foreign key. The write path filled it with `actor ?? 'system'`, so every + metadata write without a caller actor (boot sync, migration, an internal call) + stored the **string** `'system'` in a column whose declared type says "the id + of a `sys_user` row". No such row exists, and `SystemUserId.SYSTEM` + (`'usr_system'`) is not auto-provisioned on the current runtime either, so the + value resolved to nothing under any reading. Any consumer that read the field + by its declaration — `expand`, an owner column in a report, an audit timeline + showing "who changed this" — got an id that could not be dereferenced. + + It had already cost twice. #4441 had to exempt every `readonly` field from the + write-path referential-integrity check, because otherwise ordinary metadata + authoring (package create / publish / clone) was rejected. #4551's + dangling-reference audit had to skip the same set for the same reason. The + field ended up the platform's only reference column that is neither enforced + nor audited. + + **The fix is on the write path, not the declaration.** `recorded_by` stays a + `lookup('sys_user')`; an actor-less write now stores `NULL`, and `NULL` means + "system-initiated (boot sync, migration, scheduled job)" — the standard + expression of "no link", and already what this column's `set_null` delete + behaviour means. No magic system-user account (a row that can never sign in yet + holds an identity is a new security surface), and no `actor_kind` companion + column. + + **Breaking — the repository contract is now explicitly nullable.** + + | Surface | Before | After | + | :---------------------------------------- | :------- | :------------------------------------ | + | `PutOptions.actor`, `DeleteOptions.actor` | `string` | `string \| null` (still **required**) | + | `MetadataEvent.actor` | `string` | `string \| null` | + | `MetadataItem.authoredBy` | `string` | `string \| null` | + + `actor` stays required rather than becoming optional on purpose: every call + site must state which of the two it is, so a forgotten actor cannot silently + become a fake foreign key. Migrating a caller: + + - **Writers** — passing a real identity: unchanged. Passing `'system'`, `''`, + or a label to satisfy the type: pass `null` instead. + - **Readers** — `event.actor` and `item.authoredBy` can be `null`. Handle it at + the point of display (`actor ?? 'System'` in a UI string is fine — the fix is + that the _stored_ value no longer lies, not that no label may ever be shown). + + Two read paths also stopped inventing a value: `SysMetadataRepository.history()` + and `getByHash()` rendered an absent actor as the string `'unknown'`, which is + indistinguishable from a real user id to anything that resolves the field. They + now surface `null`. + + **Existing rows: `os migrate recorded-by`.** The stored `'system'` values are + rewritten to `NULL` by a new command, which runs the conversion through the + ADR-0119 D2 migration journal (chunk-atomic, resumable via `os migrate resume`). + It is a dry run by default and safe to re-run — it selects only rows still + holding the sentinel, so a second `--apply` converts nothing. + + The rewrite is **semantically equivalent, not a reinterpretation**: this column + has only ever held that one sentinel, written by exactly one expression + (`actor ?? 'system'`), and both spellings mean "no actor" — only `NULL` is + expressible in the declared type. + + Deliberately unchanged: `sys_metadata_audit.actor` is a `text` column whose + declaration already says "user id, system id, or `'system'`", so its `'system'` + default is honest and stays. The #4441 `readonly` narrowing and the #4551 audit + skip also stay — see the PR for why they are still correct. + +### Minor Changes + +- 0800433: Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`). + + New advisory rule `action-no-placement`: an action that declares no + `locations` and that no list view places by name renders on **no** surface — + it parses, publishes, and appears in Setup, while no user can ever click it. + ADR-0078 names this shape in its opening paragraph and Phase 3 asks for + exactly this rule; the shared completeness predicate it envisioned was never + built, so this lands standalone, one verified shape at a time. + + What made it verifiable now: objectui#3142 collapsed four disagreeing + renderers onto one placement predicate. Before that, `action:bar` and the + record header rendered an _undeclared_ action anyway, so the shape only looked + inert on paper. As of objectui 17.1 it is measurably inert. + + Two things are deliberately **not** flagged: + + - **`locations: []`** — the documented headless action (callable over REST / + MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a + headless declaration is the only legal way to expose one. The rule therefore + distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key + absent) and only reports the latter. + - **Actions a view places by name** — `bulkActions`, `bulkActionDefs` + (including `execution: 'aggregate'` defs, whose whole point is an action with + no single-record home) and `rowActions`, across all three list-view tiers: + `views[i].list`, `views[i].listViews.` and the object-embedded + `objects[i].listViews.`. + + Advisory, never fatal — a view in another installed package may be the one + placing the action, the same reason `validateSemanticRoles` and + `lintLivenessProperties` warn rather than gate. + + Also: the action form schema in `@objectstack/metadata-protocol` no longer + declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()` + tombstones in spec 17, and this schema is what the Studio designer renders its + fallback form from — so advertising them handed authors two inputs that could + only ever produce an unsaveable draft (objectui#3145 removed the matching + dedicated controls). And `content/docs/ui/actions.mdx` now says which surface + is the exception to location filtering, instead of a blanket claim its own + showcase contradicted. + +- 98877c9: feat(spec,metadata-protocol): `IObjectQLEngine.transaction` joins the slot contract, and `batchData`'s `atomic` flag becomes real — rollback or refusal, never silent best-effort (ADR-0119 D1/D4, #4612) + + **D1 — the contract fix.** `ObjectQL.transaction()` — ADR-0034's ambient + transaction, shipped since v8.0.0 — was reachable from plugin space only + through `as unknown as` casts: the metadata protocol's atomic publish and its + `transactionalBatch` discovery probe, and the sys-metadata repository's + `withTxn`, each declared a private structural slice of an engine none of them + import. It is now declared on `IObjectQLEngine`, required per that contract's + own rule, with its caveats written into the TSDoc as part of the declared + meaning rather than left to be discovered: it covers the **default driver + only**, and when that driver has no `beginTransaction` the callback runs with + no transaction and no rollback. `MetadataHostEngine` and the sys-metadata + repository's engine surface now type their optional member as + `IObjectQLEngine['transaction']`, so a narrow host surface can no longer drift + from the real signature. Runtime `typeof === 'function'` probes stay — that is + test-double defence the type system does not replace. + + **D4 — the honesty fix.** `batchData`'s `options.atomic` promised "rollback + entire batch on any failure (transaction mode)" and delivered a `break` + statement. Every write before the failure stayed committed, and — the part that + did the real damage — the response reported those rows `success: true` under + the one flag whose job is to guarantee they were undone. + + Now an explicitly atomic batch runs inside ONE `engine.transaction()`: the + first failure rolls back every prior write, and the response says so + (`succeeded: 0`, with rows marked `ROLLED_BACK:` / the causal error / + `NOT_ATTEMPTED:`, and no row reporting success). On a runtime that cannot roll + back — no `transaction()`, or a default driver without `beginTransaction` — an + atomic request is **refused** with `501 NOT_IMPLEMENTED` rather than silently + degrading, matching the cross-object `/batch` route. `atomic` takes precedence + over `continueOnError`, whose own description already scoped it to + `atomic=false`. In atomic mode the upsert path no longer falls back to an + insert when its update throws: inside an aborted transaction that fallback can + only fail with a secondary error that buries the real cause. + + **Aligned declaration.** `BatchOptionsSchema.atomic` declared `.default(true)` + while no enforcement site delivered atomicity — and the REST route forwards the + original request body rather than the parsed output, so the declared default + never reached the loop at all. The default is now `false`: the declaration is + aligned down to what every site already does, rather than up to what none of + them did. Honouring the old `true` would have silently flipped the failure + semantics of every existing batch caller and hard-failed ordinary batches on + any driver that cannot transact. Callers who were explicitly sending + `atomic: true` now get what they always asked for; callers sending nothing keep + today's behaviour exactly. + + If you were passing `atomic: true` and relying on partial results surviving a + failure, that was the bug — switch to `atomic: false` (or omit it) for + best-effort semantics. + + ADR-0119 also rules on two items landing separately: D2 specifies a + framework-owned migration-journal runner for multi-step migrations too large + for one transaction, and D3 retires the declared-but-unimplemented + `IDataEngine.batch?`. + +- 4c80fd6: fix(metadata-protocol): `deleteMany` / `updateMany` honour `atomic` for real, or refuse it (#4620) + + ADR-0119 D4 made `batchData`'s `atomic` flag a real guarantee. Its two siblings + in the same file were out of that PR's confirmed scope and kept the defect: + + - **`deleteManyData` was fake-atomic.** `atomic: true` opened no transaction; it + only `break`-ed the loop, so every row deleted before the failure stayed + **deleted** while the response called itself atomic and reported those rows + `success: true`. Worse than the `batchData` case it was copied from, because a + partial delete has no natural undo — a client cannot reconstruct the rows from + its own request. + - **`updateManyData` ignored `atomic` entirely.** The option was accepted, + declared in `BatchOptionsSchema` with an all-or-nothing contract, and never + read: a caller asking for atomicity silently got best-effort, with no signal. + + Both now run the **same** atomic arm as `batchData`, extracted into one shared + runner so a fourth copy of transaction handling cannot drift into a fourth lie: + + - `atomic: true` runs the whole batch inside ONE `engine.transaction()`; the + first failure rolls back every prior write. + - A rolled-back batch reports **zero successes**. Rows that had succeeded are + marked `ROLLED_BACK: record failed — `, rows never reached are + `NOT_ATTEMPTED: atomic batch aborted by record `, and the causal row keeps + its own error — so a client can tell "attempted, undone" from "never ran". + - `atomic` outranks `continueOnError`, whose contract text already scoped it to + `atomic=false`. + + **Behaviour change to be aware of:** a runtime that cannot roll back (no + `engine.transaction()`, or a default driver without `beginTransaction`) now + **refuses** an `atomic: true` `deleteMany` / `updateMany` with `501 +NOT_IMPLEMENTED` instead of silently running best-effort — the same fail-closed + gate `batchData` uses. That silent downgrade is the defect class this fixes; if + you want best-effort, ask for it (`atomic: false`, or omit the option), or probe + the runtime's transaction support before sending. Non-atomic behaviour of both + endpoints — including the `continueOnError` interaction and their response + shapes — is unchanged. + +- 83cf2d3: feat(migrate,metadata-protocol): `os migrate meta --stored` rewrites sys_metadata rows so the read-path chain has a finish line (#4327) + + #4317 closed the correctness gap from the read side: every stored-row + rehydration seam replays the full ADR-0087 conversion chain, retired entries + included, so a row written under any past protocol is _served_ canonical + forever. What it deliberately did not do is make the rows themselves canonical. + A pre-17 row keeps its legacy bytes, the chain re-lowers it on every load, and + each affected row logs one conversion notice per process — deduped, but back + every boot. Until now the only things that ever rewrote such a row were a Studio + re-save and `duplicatePackage`. + + **`os migrate meta --stored`** is the pass that ends it for a deployment that + runs it. It walks `sys_metadata` — `active` and `draft`, every organization — + replays the same `applyConversionsToStoredItem` chain, and re-saves each changed + body through the normal write path, so a rewritten row gets a + `sys_metadata_history` entry, a fresh checksum and the mutation projectors, + exactly like an author's save. The history row's `source` is `migrate-stored`, + so a later diff distinguishes an upgrade from somebody's edit. + + ```bash + os migrate meta --stored # preview: per-row report, writes nothing + os migrate meta --stored --apply # rewrite the rows (prompts) + os migrate meta --stored --apply --yes --json # CI / scripts + os migrate meta --stored --type view # restrict to a type (repeatable) + ``` + + **Preview is the default and `--apply` is the only writing mode** — the house + rule its siblings already keep (#3617's "a dry run changes nothing"), and it + applies with more force here because what moves is metadata: every affected + row's checksum and a history entry per row. An apply run also refuses to start + while another process holds the SQLite database, for the same reason + `os migrate files-to-references --apply` does. + + **Nothing gates on this having run.** #3855's conclusion stands — an + operator-run migration cannot be relied upon, so the read path remains the + guarantee for every deployment, and no `sys_migration` flag is recorded (a flag + would advertise enforcement that does not exist). What a run buys is hygiene — + rows stop carrying pre-protocol dialects, so diffs, exports and history are + clean going forward, and the recurring notices go quiet — plus one thing that + was previously unobtainable: **an operator can assert it.** A run with nothing + left to do exits `0`, a deployment with rows still on an old dialect exits `1`, + so "my metadata is on protocol N" becomes a CI check rather than a belief. + + Three things the pass declines, and reports rather than counting as done: + `flow` rows (their seam is `AutomationEngine.registerFlow`, which holds the + executor registry the node-type conflict guard needs), types with no repository + write path (`agent` — rewriting there would record no history and force a draft + live), and rows that still fail the current schema after conversion (a genuine + contract violation the write path is right to refuse; it keeps reading through + the chain and stays fixable in Studio). + + Also new, and usable without the CLI: `protocol.migrateStoredMetadata()` returns + the same structured report an admin route would render, and `saveMetaItem` + accepts an optional `source` for the history/audit rows. `source` is not + request-derived — the REST layer builds its save request field by field and + never forwards a client-supplied value, so provenance stays something the server + states rather than something a caller claims. + +- 4b945fc: Author-time rules now gate the RUNTIME metadata write path, not just the CLI (#4463) + + The 26 author-time rules `os validate` / `os build` / `os lint` share (#4409) ran on + those three commands and nowhere else. Every runtime metadata write — Studio's + designer, REST `/meta` item CRUD, an MCP/AI agent authoring a flow — reaches + `saveMetaItem`, which did a per-type Zod `safeParse` and stopped. For a tenant that + was not the weakest of four doors, it was the **only** door: a `sys_metadata` + overlay row is not in the CLI's config file, so there was no command they could run + instead. An approval flow whose `expression` approver is broken CEL + (`record.owner ==`) is Zod-valid, so it saved, registered, and failed at the node's + entry the first time it fired — the exact body `os lint` had rejected since #4409. + + **One shared core, one runtime gate.** + + - The rule registry moved from `packages/cli` into `@objectstack/lint` + (`AUTHORING_RULES`), and the CLI now calls it there. Five rule modules moved with + it (`lintFlowPatterns`, `lintLivenessProperties`, `lintAutonumberFormats`, + `lintViewRefs`, `data-model-rules`), unchanged. There is one table; a second one + cannot be introduced without failing `authoring-rule-wiring.test.ts`. + - New kernel-safe subpath export **`@objectstack/lint/runtime`** — the entry the + metadata write path imports. Running the gate loads neither `typescript` nor + `sucrase`, pinned by a new `runtime-lazy-deps.test.ts` alongside the existing + `lazy-deps.test.ts`, which is unchanged. + - Each registry entry now declares `surfaces` (`cli` / `runtime-publish`) plus + either the metadata `runtimeTypes` it judges or a written `surfaceReason`. The + ratchet fails an entry that answers neither. + + **Behaviour** + + - A `state: 'active'` `saveMetaItem` — and the draft→active promotion in + `publishMetaItem` — of a **flow** runs the flow / approval / expression / + reference rule families. A gating finding is refused with **422 + `INVALID_METADATA`**, in the same structured envelope the Zod failure already + used, with `rule` / `path` / `where` / `message` / `hint` per issue. + - **Draft saves are never gated** — a draft is allowed to be half-finished and + cannot execute. + - Only the write is judged: the rules run twice (context with and without the + submitted item) and only findings the item _added_ can refuse it, so a + pre-existing violation in a stored row never blocks an unrelated save. Stored + rows keep being read. + - Escape hatch **`OS_ALLOW_UNLINTED_METADATA_WRITES=1`** turns the refusal into a + loud log for a migration window. Unset it once the metadata is fixed — the + runtime executes what it published. + + Only `flow` writes are gated in this pass; every other metadata type carries a + recorded reason in the registry. + +- 304423e: feat(automation,migrate): `os migrate meta --stored` now covers flow rows too (#4454) + + #4327 gave the stored-metadata conversion chain a finish line for every + metadata type except `flow` — the one type where the most stored dialect + actually lives, since the graduated conversions `flow-node-crud-filter-alias`, + `flow-node-crud-object-alias`, `flow-node-notify-config-aliases` and + `flow-node-script-config-aliases` are all flow-node entries. Flow-node + conversions carry ADR-0078's open-namespace conflict guard, which has to consult + the _live_ executor registry to tell a rename from a clobber, and the metadata + layer has no way to obtain one. Flows were reported `skipped` with that reason. + They are now converted. + + **One canonicalization policy, two shapes.** + `AutomationEngine.canonicalizeStoredFlow` is the single implementation and + `registerFlow` calls it, so the load seam and the migration can never disagree + about what "canonical" means. It returns `parsed` (for execution — the + `FlowSchema.parse` + #4347 region output, schema defaults materialized) and + `storable` (for persistence). + + **`storable` excludes schema defaults, and that is the load-bearing decision.** + Measured rather than assumed: driving a pre-17 flow through all three steps + _removes_ nothing — `FlowSchema` is strict since #4001, so an unrecognized key + throws instead of being silently dropped, which means the + `graftNormalizedOperators` precedent (it exists because the _view_ parse strips + Studio-only auxiliary keys) does not transfer — and _adds_ only defaults: + `version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the + author never wrote would pin every migrated row to today's value while untouched + rows follow tomorrow's: two populations with different behaviour, which is + exactly the drift this pass exists to remove. So the write-back is the + conversion result plus the `{dialect, source}` envelopes the schema derives for + edge conditions, and nothing else. + + One subtlety worth knowing if you extend this: that envelope is a schema + transform, not a conversion, so it emits **no** notice while still changing the + body. Reading notices alone — correct for every other metadata type — would call + such a row canonical and leave it re-deriving on every boot. Both passes are + copy-on-write, so identity is the exact test for flows. + + **New: `AutomationServicePluginOptions.armRuntime`** (default `true`, so every + server, dev stack and test host is unaffected). Set `false` and the plugin + brings up the engine and the complete node registry — built-ins plus whatever + `automation:ready` contributes, because a _partial_ registry would make the + conflict guard read a live custom node type as unowned and rewrite over it — and + then stops before anything is armed: + + | Skipped when `armRuntime: false` | Why it must be | + | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | + | flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live | + | declarative connector materialization | opens real connections; an MCP provider spawns a child process | + | suspended-run wait-timer re-arm | would resume someone's paused approval mid-migration | + + `os migrate meta --stored` boots the plugin in that mode. A migration process + must not become a second server. + + A refused rename — the guard firing because the old node-type token is a live + name something else owns in this environment — fails that row loudly, naming the + token and its owner. Never a silent skip, never a clobber. A flow that cannot + canonicalize at all (a strict-schema violation, a malformed control-flow region) + is reported as failed with the parse message rather than persisted as a guess; + such a row cannot register today either, so the report is telling you about a + flow that is already broken at runtime. + +- ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + + Four independent surfaces where the answer a caller received contradicted the + contract the surface declares. All four were found driving a real showcase boot + against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + + - **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + + - **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + + - **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + + - **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + + These change responses that were observably wrong, so callers coded against the + buggy shapes — a 200 on a missing record, a 400 on a capability denial — will + see different status codes. Graded `minor` on that basis rather than `patch`. + +- ce92674: feat(spec)!: retire the standalone `validation` metadata kind (#4509, ADR-0088) + + A validation rule authored as its own artifact bound to nothing and gated no + write. `ValidationRuleSchema` carries **no object-binding key** — no `object`, + no `objectName` — and all six variants are `strictObject`, so an author could + not supply one either. No merge step existed. The only code that expected such a + key was a reference-tracker row scanning a field the schema would have stripped. + Meanwhile the engine evaluates exactly one shape: the object's own + `validations[]` array, on insert and on every matched update row. + + So a rule created through the standalone door — a `*.validation.ts` file, or + Studio's Validations list — parsed, saved, reported success, and intercepted + nothing. Including a `state_machine` rule, which ADR-0020 routes through this + same vocabulary: an author could believe they had locked down record state + transitions and have changed nothing at all. + + Under ADR-0088 the kind fails the admission test on its first clause: a rule has + no independent lifecycle, because it only means something against an object. And + unlike the sibling disconnects closed in this batch, it could not be bridged into + one — the shape has nowhere to name its object. + + **The rule vocabulary is untouched.** `ValidationRuleSchema` and all six + variants are unchanged and fully live; the engine's evaluation path is not + modified by this change. It is the _kind_ that was inert, not the schema. The + liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS` + override (alongside `webhook` and `query`), because an ungoverned live schema is + exactly how the next drift would hide. + + **Migration.** Move the rule into the owning object's `validations:` array — the + rule body is identical, same schema, same six variants: + + ```ts + // before — a standalone *.validation.ts, which never ran + export default defineValidation({ name: 'amount_positive', type: 'script', … }) + + // after — on the object, where rules are evaluated + ObjectSchema.create({ + name: 'invoice', + validations: [{ name: 'amount_positive', type: 'script', … }], + }) + ``` + + Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml` + patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum + member, the schema-map entry, the create seed, Studio's Validations nav item and + its hand-crafted form, and the dangling reference-tracker row. Standalone rows + already in `sys_metadata` are left alone — they were never evaluated, so nothing + changes behaviorally. + +- dadb43f: refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451) + + The `workflow` slot was ADR-0078's silently-inert declaration at every layer at + once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115 + Evidence 5 — "no code in this repository resolves either slot", verified across + both repositories), an `IWorkflowService` contract with zero implementations, a + `WorkflowProtocol` whose three methods no code ever provided, a discovery + `routes.workflow` field no builder could truthfully populate, and a + `/api/v1/workflow` advertisement for a path no host ever mounted (the + pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that + never existed). The capability it promised is live elsewhere and has been for + majors: record state machines are enforced by the `state_machine` validation + rule, approvals are first-class flow nodes on the approvals runtime + (ADR-0019), and record-triggered automation is lifecycle hooks + + `record_change` flows (`service-automation`). + + FROM → TO: + + - `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` / + `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill. + - `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no + implementation ever existed. Register nothing — use the mechanisms above. + - `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`, + `WorkflowState`, `GetWorkflowStateRequest/Response`, + `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed, + along with the seven published JSON schemas. Delete the import; nothing + ever answered these shapes. + - Discovery `routes.workflow` / `services.workflow` / `features.workflow` + (metadata-protocol + runtime builders) → absent. A reader keying on them + only ever saw `unavailable` / `false`; delete the read. + - `RouterConfig.mounts.workflow` → removed; there was never a surface to + mount at it. + - `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent + routes as `'automation'`. + - `@objectstack/client` re-exports of the four workflow types → removed with + their source. (The `client.workflow.*` methods were already removed earlier + in the v17 cycle — this retires the types they returned.) + - Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the + `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a + `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out + of the product plan (#2462 follow-on). + + The retirement kit: the `workflow-service-slot-retired` semantic migration + (major 17) carries this prescription into `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a + discovery response field — never stored in stack metadata — so there is no + load-path conversion and nothing for `os migrate meta` to rewrite; the + 21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` + entries for the deleted schemas are dropped deliberately in the same change + (the plugin-runtime precedent: a prescription nobody can receive is noise — + nothing parses these shapes any more). + +### Patch Changes + +- 98877c9: feat(core,platform-objects,spec): the ADR-0119 D2 migration-journal runner — a migration killed mid-run is resumable to completion or compensable to clean, with journal rows proving which (#4617) + + **The gap D1 left open.** ADR-0119 D1 made `engine.transaction()` reachable + through the contract, which is the right answer for multi-write atomicity that + fits in one transaction. Migration-class work does not fit: a million-row + backfill cannot hold one write-lock for its duration, `driver-memory`'s + `beginTransaction` deep-clones the entire database (O(db) per begin), + `ObjectQL.transaction()` binds the **default driver only** so a multi-datasource + migration silently commits part of its work outside it, and a process **killed** + — as distinct from a thrown error — defeats in-process rollback entirely. So the + unit of atomicity is the _chunk_, and durability across chunks is a journal. + + Four consumers had each converged on the same four moves — dry-run preflight, + undo journal, LIFO compensation, re-entrant forward recovery (ADR-0105 D13 + promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, and + D10 master-data distribution #4585). One copy is engineering; four is platform + debt, and the fourth author would have had to rediscover the invariant below + from scratch. + + **New: `runMigrationJournal` (`@objectstack/core`).** Preflight runs every + step's read-only validator before any step writes, so a plan that would fail at + step 3 has not written step 1. Rows are chunked per the `bulk-write.ts` + discipline; each chunk's writes run inside `engine.transaction()`. On failure, + committed chunks are compensated newest-first, each in its own transaction. On + restart, a rediscovered run resumes forward from the first chunk lacking + `chunk_done`, or unwinds, per the plan's `onCrash` policy. Forward and + compensate callbacks receive an `attempt` counter; `attempt > 1` means the prior + outcome is UNKNOWN and the callback must recheck by natural key before + re-writing — the same at-least-once contract `bulk-write.ts` already documents, + reused rather than re-derived. + + **The invariant that carries the design:** `chunk_done(i)` is written **inside** + the chunk's own transaction, so `done ⇔ committed` holds by construction; + `chunk_started(i)` is written autonomously **before** it. That asymmetry is what + gives `started ∧ ¬done` exactly one meaning — _the outcome is unknown_ — which + is the only state a crash can leave and the only state recovery reasons about. + Making both writes symmetric would look tidier and would destroy recovery. + + **New: `sys_migration_journal` (`@objectstack/platform-objects`).** Rows keyed + `(run_id, seq)` under a unique index, so a resumed run that miscomputes its next + sequence fails loudly rather than double-recording an event. Registered + unconditionally alongside `sys_migration` because recovery must be discoverable + with **zero host wiring** — a journal some kernels compose and others do not is + a journal a boot scanner cannot rely on (ADR-0078). Distinct in grain from + `sys_migration`, which holds one durable verdict per named migration; this holds + many rows per _run_. Read-only over the API; writes go through the runner in + system context. + + **The runner refuses rather than degrades**, in four places: the runtime cannot + roll back; any preflight fails; the plan declares `onCrash: 'compensate'` but a + step cannot compensate; or a resume's plan hash disagrees with the journal + (resuming a changed plan would apply chunk boundaries the journal never + described). A compensation failure halts and is journalled — never swallowed — + and the run ends `failed`, not `compensated`, because a database in a state no + clean story covers must not be reported as a tidy rollback. + + **`engineCanRollBack` is now shared.** The two-level probe (engine method AND + default-driver `beginTransaction`) was the same condition written twice — here + and in `batchData`'s atomic gate. It now lives in `@objectstack/core` and + `@objectstack/metadata-protocol` imports it, as a type predicate so callers do + not each re-narrow the optional member by hand. Two copies of "can this runtime + actually roll back?" drift by one clause and leave one caller believing it has + atomicity it does not have. + + Boot reconciliation and `os migrate resume` land separately; `findInterruptedRuns` + is the discovery primitive they will consume, and is exported here. + + **Docs:** ADR-0118 (plugin-reachable transactions) is renumbered **ADR-0119**. + It merged one day after an unrelated ADR-0118 (非用户 actor 的平台契约) and the + earlier merge holds the number; citations of "ADR-0118 D1/D2/D3/D4" written + before 2026-08-03 mean the renumbered record. + +- 58434f5: fix(metadata-protocol): boot hydration grafts each overlay row's protection envelope from ITS OWN package (#4624) + + `loadMetaFromDb` (boot hydration) kept a **third** inline copy of the + overlay→SchemaRegistry registration rule, and its artifact lookup was + **unscoped** — the exact pre-#1828 shape ADR-0048 removed from `getMetaItems`: + with two installed packages shipping the same `type`/`name`, a name-colliding + overlay row grafted the **first-registered** package's + `_lock`/`_lockReason`/`_packageId`/`_provenance` onto another package's row at + every kernel boot. A row customized under package B could come up wearing + package A's identity and lock. + + The non-object branch now delegates to the ONE shared + `hydrateOverlayIntoRegistry` (introduced by #4521 for the read-side hydration + and the write-through), passing the row's own `package_id` — one rule, one + implementation, and the ADR-0048 package-scoped lookup applies at boot exactly + as it does on read and write. + + No other boot behaviour changes: + + - **Boot order** — when packaged artifacts have not loaded yet at hydration + time, the scoped lookup finds nothing, exactly like the unscoped one did, + and the row registers unchanged. + - **Package-less (global) rows** — `package_id IS NULL` keeps the legacy + best-effort first-match graft, identical to the read-side hydration. + - **Row selection** — the helper carries no environment gate; which rows + `loadMetaFromDb` loads is decided by its query, unchanged here. + +- 5b843fb: fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations (cloud#971) + + `getMetaItems({ type: 'flow' })` decorates every served item with + `_diagnostics` (and `_draft` on a preview read). The cold-boot bind fed that + served document straight into `engine.registerFlow` → `FlowSchema.parse`, and + since #4001 closed the metadata schemas an unrecognized key **throws** instead + of being dropped — so every flow failed to register on every boot with + `unrecognized_keys: ["_diagnostics"]`. Not fatal only by luck: the + record-change plugin binds record flows a second way, so automations kept + firing behind one WARN per flow. A flow whose only binding path is this one + would have gone silently dead. + + Fixed at the read seam (`readFlowDefsFromProtocol`), not by loosening + `FlowSchema`: the payload is malformed because we decorated it, so the + producer's annotation is the producer's to remove. + + `@objectstack/spec` gains `METADATA_READ_DECORATIONS` / `stripReadDecorations` + (`kernel/metadata-read-decorations`) — the list moves out of + `metadata-protocol`, where it was module-private, so the producer and its + cross-layer consumers share one definition. `metadata-protocol` re-exports + `stripReadDecorations` unchanged; no public surface is removed. + +- 20bc357: fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318) + + The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue` + and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no + dispatcher domain, no adapter mount, no plugin registration, and the shipped + providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will + never mount one. Every default boot therefore advertised a route inside the same + `ServiceInfo` whose `handlerReady: false` said the opposite — a single record + contradicting itself (ADR-0076 D12). + + These slots are route-less now, like `realtime` — but unlike `realtime` an + unmarked real implementation stays `available`: the slot's contract is + in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady` + is reported `false` on both discovery builders — for a route-less slot it is not + a proxy for anything, it is the fact itself (the dispatcher used to claim + `handlerReady: true` here for an unmarked occupant, a handler that does not + exist). The explanatory message is written once, as + `inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two + builders cannot drift apart. + +- 8aacf94: fix(metadata-protocol): `duplicatePackage` stops minting pre-protocol flow rows (#4498) + + `duplicatePackage` canonicalizes each source row before re-saving it, under a + stated guarantee: "duplication never mints new rows in a pre-protocol dialect." + It delivered that through `convertStoredItem`, which opens with + `if (singular === 'flow') return { item: data, notices: [] }` — so for flows the + guarantee was **not** delivered. + + It did not fail loudly either. `FlowNodeSchema.config` is an open `z.record`, so + a pre-17 body (a `delete_record` carrying `config.filters`) sails through + `saveMetaItem`'s schema gate and lands verbatim in a brand-new row. + + **Why this mattered more than an un-migrated row.** ADR-0087 justifies the whole + stored-metadata design on new writes always being canonical, _therefore_ the + stored pass being "a strictly shrinking concern". `duplicatePackage` was a live + producer contradicting that for flows: an operator could run + `os migrate meta --stored --apply`, get a clean report, duplicate a package, and + be back to having pre-protocol rows — with the report still saying protocol N + until the next run. + + **The capability was already reachable.** The reason for the flow skip is real — + flow-node conversions carry ADR-0078's open-namespace conflict guard, which needs + the automation engine's live executor registry to tell a rename from a clobber. + But the protocol is constructed with an accessor for the kernel's service table + (the same one `analytics` and `package` are read from), and the automation + service registers under `automation`. A new private `resolveFlowCanonicalizer` + reads `canonicalizeStoredFlow` (#4454) off it, so every caller running next to a + live engine gets flow coverage without threading anything. + + - **`duplicatePackage`** canonicalizes flow rows through it. A refused rename + fails that item into the existing `failed[]` naming the token — copying the + un-renamed body would mint exactly the row this fixes. A flow that cannot + canonicalize fails the same way. With no engine reachable (a control-plane or + metadata-only host) the source body is copied as-is: no worse than the source + row already is, and failing an unrelated duplication over it would be its own + regression. + - **`migrateStoredMetadata`'s `canonicalizeFlow` becomes an override.** It now + defaults to the resolver. The CLI stopped passing one — it boots its inert + engine into the same kernel, so both routes reached the same instance, and two + routes to one capability is how they drift. The parameter stays for callers + with no registry and for testing the flow branch without an engine. + - **Resolution is lazy, per call.** Plugin init order does not guarantee + `automation` is in the table when the protocol is assembled (the CLI adds it + after ObjectQL by design), so caching `undefined` from a too-early read would + disable flow canonicalization for the life of the process. + + Two smaller honesty fixes ride along: a source item that fails _conversion_ (a + tombstoned key throws) is now reported as such instead of as `unparseable +metadata`, and `migrateStoredMetadata`'s "no engine" skip reason says no + automation service is reachable rather than blaming the caller for not supplying + one. + + Reads are unchanged. `getMetaItems` / `getMetaItem` / `getMetaItemLayered` / + `loadMetaFromDb` still skip flows — they are reads, covered by `registerFlow` + canonicalizing at execution, and are not producing bad data. Duplication was the + one that writes. + +- 63b33e6: One canonical type key at the `/meta` read/write/delete boundary (#4432). + + #3985 made the per-type gates accept both spellings of the `/meta` type segment + (`/meta/actions` and `/meta/action`). It did not FOLD them, so the two spellings + addressed two different namespaces and the layers below disagreed about which + one an item lived in. `saveMetaItem`, `getMetaItem`, `getMetaItems`, + `getMetaItemLayered`, `getMetaItemCached` and `deleteMetaItem` now fold the type + to its canonical singular (Prime Directive #3) as their first act, so every layer + below them reads one key. + + The damaging consequence was not the duplicate row — it was the shadowing. + `getMetaItems` hydrated overlay rows back into the SchemaRegistry under the + CALLER's spelling, so one plural-spelled read minted a plural registry entry; + from the next read on, `listItems('actions')` was no longer empty, the singular + fallback that had been supplying every code-authored action stopped running, and + a single overlay row hid the entire code-authored listing — on a spelling no + DELETE could address, because the delete path resolved the singular. Listing and + dispatch then disagreed about an item that had been deleted. + + Reads of data AT REST still try the other spelling as a fallback: rows written + under a plural `type` before this fix are real, and nothing rewrites them on + upgrade. What changed is that nothing WRITES or REGISTERS a non-canonical key any + more. + +- 6beb708: fix(metadata-protocol): a just-saved overlay is dispatchable immediately, not after the next listing (#4521) + + The #4432 F1 verification found that immediately after a successful + `PUT /api/v1/meta/action/`, `GET /api/v1/meta/action` already listed the + overlay while `POST /api/v1/actions//` answered the ADR-0110 + "has no declaration" 404 — and a later POST succeeded. Nothing expired in + between: the _listing_ is what repaired it. + + The lagging cache was the engine's `SchemaRegistry`. The runtime dispatch path + (`resolveRouteActionDeclaration`) reads it as the live view of metadata, but + `saveMetaItem` only wrote through it for `object` — every other overlay type + reached the registry solely via the READ-side hydration in `getMetaItems`, so + "has anyone listed this type yet?" silently decided whether a saved action + could be invoked. + + The fix is at the producer, per Prime Directive #12 — no retry, sleep, or + fallback was added at the dispatch site: + + - `saveMetaItem` (publish mode), draft publishing (`runPublishSideEffects`), + and `rollbackMetaItem` now write EVERY overlay type through the registry via + a shared `applyRegistryWriteThrough`, so an item that is listable is + dispatchable in the same breath. + - The write-through and the read-side hydration share one implementation + (`hydrateOverlayIntoRegistry`), including the ADR-0010 §3.3 protection-envelope + graft and the ADR-0048 package-scoped artifact lookup — a read and a write + can no longer leave the registry in two different states for the same row. + - Unchanged boundaries: drafts still never leak into the live registry, the + `environmentId` scoping gate matches the read side, ADR-0110's 404 for a + genuinely absent declaration stands, and DELETE ("reset to artifact default") + still restores the packaged artifact — the overlay is a plain-key shadow, not + an in-place overwrite. + +- 69b509f: fix(metadata-protocol): 元数据审计历史与全局搜索按 `order` 排序,不再按 `direction` (#4674) + + `protocol.ts` 里两处内部 `engine.find` 调用把排序写成 `{ field, direction: 'desc' }`。QueryAST 的排序形状是 `SortNodeSchema` = `{ field, order }`,两个真实驱动都只认 `.order` 且没有 `direction` 回退——`undefined === 'desc'` 为假,于是两个查询实际都在**升序**运行。`direction` 是 `IReportService` 的词汇,是另一份契约,这正是错误拼写看起来合理的原因。 + + 由于两个查询都带 `limit`,方向错误不只是把一页重排,而是**改变了哪些行会被返回**: + + - **元数据审计历史**取到的是最旧的 `limit` 条事件——一个对象生命的开头,而永远不是它最近的变更。在长期存在的对象上,编辑者要找的东西一条也看不到。 + - **全局搜索**取到的是最陈旧的 `perObject` 条匹配,最近编辑过的记录恰好被 `limit` 截断掉——而那正是搜索者最可能想要的。 + + 两处的 `as any` / `: any` 一并去掉:`EngineQueryOptions.orderBy` 是 `SortNodeSchema[]`,本来就会拒绝 `direction`,而类型擦除正是让它溜过去的原因。恢复类型是这次改动价值的大头,因为对内部调用方来说 `tsc` 就是那条被执行的渠道。 + +- 1ee48bc: fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact + + `saveMetaItem` refuses to write an artifact-backed item of a type that has not + opted into overlay writes (`not_overridable`), and it asks + `registry.getArtifactItem` who is artifact-backed. That answer was "anything + whose `_packageId` is not the literal string `sys_metadata`" — a sentinel that + only holds on the save path. The boot-time rehydration of `sys_metadata` + registers each row under its REAL package id (`app.`), which every + runtime-authored item has carried since packages became mandatory. + + So an app the user had just built through Studio (or the AI build agent) came + back from the next kernel rebuild looking code-shipped, and the following edit + was refused with a 403 — permanently. Live capture: two identical `modify_field` + calls on the same object seconds apart, the first published LIVE and the second + `not_overridable`, because the first one's auto-publish triggered the rebuild in + between (cloud#970). + + Provenance is the axis that actually separates the two (ADR-0010 `_provenance`: + `'package'` for loader-introduced items, `'org'` for tenant-authored), so ask it: + the `sys_metadata` hydration now stamps `_provenance: 'org'`, and + `getArtifactItem` no longer treats such an item as an artifact. An item with no + provenance under a real package id is unchanged, so nothing that was protected + becomes writable. + +- 705e5c8: fix(metadata-protocol): a flow save that skipped canonicalization says so (#4580) + + `saveMetaItem` canonicalizes flow bodies before the schema gate (#4542). When the + canonicalizer throws — it is stricter than the gate: strict parse, cycle + detection, control-flow region validation — the save falls back to the raw body + so a work-in-progress draft with a temporary cycle stays saveable. That fallback + is correct and unchanged. It was also completely silent. + + Of the four postures at this seam, three announce themselves: a clean + canonicalization heals the row, a refused rename fails with `409 +FLOW_CONVERSION_CONFLICT` naming the token, and a host with no automation service + is reported by `os migrate meta --stored`. The throw-fallback said nothing, so a + save that skipped canonicalization was indistinguishable from one that healed the + row — and a body that is _both_ a legacy dialect and unparseable by the strict + canonicalizer re-persisted verbatim. That is the exact #4542 symptom, arriving + silently, while the boot warning for legacy stored rows tells the author that + re-saving is the remedy. + + The fallback now emits a `console.warn` naming the flow and the canonicalizer's + own error, deduped once per flow per process (the `convertStoredItem` pattern — + Studio autosaves the same draft repeatedly, and a WIP cycle throws on every + write). This aligns the write seam with ADR-0087 D2's "loud" posture, where + conversions emit notices, reads warn once per row, and `migrateStoredMetadata` + reports `failed` with the message. + + No behavior change: the body still saves, the schema gate stays the arbiter, and + `registerFlow` still refuses to arm a malformed flow. Refusing the save in + publish mode was considered and rejected — publish is the default mode, so it + would silently tighten validation for every existing caller, and it could only be + enforced on hosts that have an automation service, making the same body saveable + on a control-plane host and a 422 on an automation host. + +- f61edce: fix(metadata-protocol): `saveMetaItem` canonicalizes flow bodies on write — a Studio edit now heals a legacy flow row like every other type's (#4542) + + The once-per-boot stored-conversion warning promises that re-saving a row + ("Studio edit → save") persists the canonical shape. That held for every type + except `flow`: the read path serves stored flows verbatim (the ADR-0078 + open-namespace conflict guard needs the engine's live executor registry, so + `convertStoredItem` skips them), and `FlowNodeSchema.config` is an open + `z.record`, so the legacy dialect an author was served (`config.filters`, pre-17 + node aliases) sailed back through `saveMetaItem`'s schema gate and re-persisted + verbatim. A flow row stayed `pending` in `os migrate meta --stored` no matter + how many times an author edited it — only the migration itself could retire it. + + `saveMetaItem` now runs the #4498 resolver (`resolveFlowCanonicalizer`) on flow + bodies **before** the schema gate and persists `storable` — conversions plus the + derived condition envelopes, deliberately not the schema's defaults (ADR-0087). + The pass is copy-on-write, so already-canonical bodies (including the ones + `migrateStoredMetadata` and `duplicatePackage` hand in) are untouched. + + Failure postures, same as the duplication seam: + + - **A refused node-type rename** (the old token is a live name owned by a custom + executor here) refuses the save with `409 FLOW_CONVERSION_CONFLICT`, naming + the token and path — never a silent legacy persist. 409 rather than 422 + because the body may be perfectly valid: the refusal comes from environment + state, so resubmitting the same body cannot help. + - **A body the canonicalizer cannot parse** falls back to the raw save and + today's schema gate — in draft AND publish mode. `canonicalizeStoredFlow` is + stricter than the gate (cycle detection, control-flow regions), and a + work-in-progress draft with a temporary cycle must not become unsaveable; + `registerFlow` still refuses to arm a malformed flow either way. + - **No automation service reachable** (a control-plane or metadata-only host): + the save behaves exactly as before — a host must not start refusing flow + writes it accepted yesterday. `os migrate meta --stored` reports what it + could not canonicalize. + + Reads are still unchanged — served bodies keep the stored dialect ("reads + diagnose, never drop"); the heal happens on the way back in. + +- 0657f6b: fix(seed): enforce `Seed.env` — environment-scoped datasets no longer seed everywhere + + `Seed.env` was authorable, defaulted and type-checked, but inert. `SeedLoaderService` + filtered on the **loader config's** `env`, and none of the six call sites that build a + `SeedLoaderRequest` (app boot, per-org replay, hot reload, package apply, draft publish, + marketplace install) ever passed one — so `config.env` was always `undefined`, the filter + short-circuited, and `dataset.env` was never read. A dataset marked `env: ['dev']` seeded + into production exactly as if it were marked `['prod']`, which is the dangerous direction: + the rows most likely to carry that marking are demo users, fake customers and seeded + credentials. + + The loader now resolves the environment itself, at the one funnel every seeding path goes + through: + + - **Source is `NODE_ENV`** — the environment source this repo already uses everywhere + (`os start` defaults it to `production`, `os dev` / `serve --dev` set `development`, + vitest sets `test`). No new environment variable and no new authorable key. `production` + / `development` / `test` and the seed-enum spellings `prod` / `dev` are accepted, + case-insensitively. + - **An explicit `config.env` still wins**, so a host can seed "as" another environment. + - **A dataset that declares no `env`** (the schema default `['prod','dev','test']`) seeds + in every environment, exactly as before — no existing deployment loses rows. + - **When the environment cannot be determined** (NODE_ENV unset, or a value like + `staging`), the loader stays permissive and seeds everything — but logs a **warning** + naming each environment-scoped dataset, the accepted `NODE_ENV` values and the + `config.env` escape hatch. Fail-open is deliberate: fail-closed would also drop an + `env: ['prod']` dataset on a production host that merely forgot to export `NODE_ENV`, + a silent data-loss regression worse than the over-seeding it prevents. + - **Skipped datasets are always named** in an `info` log, so "my demo rows are missing" is + one log line to answer rather than a mystery. + + The resolved environment is also what seed CEL expressions now bind `env` to, so a seed's + `env` and the loader's filter can no longer disagree. + + No API or schema change: `Seed.env` and `SeedLoaderConfig.env` are unchanged, and no + package export was added. + +- 666f542: fix(seed-loader): the per-org tenant stamp is an id, not a natural key — stop + re-resolving it and dropping it + + In a multi-org deployment the SeedLoader's per-organization replay landed + **every row org-less**, so a freshly created organization booted with a CRM + whose tables held data nobody could see: the tenant wall (`organization_id = +`) hides a NULL-org row from all members, including the org's own + owner. + + The stamp and the reference pass disagreed about what `organization_id` holds. + The loader writes `config.organizationId` — the replay target's **id** — into + the record; the reference pass then sees a field declared as a lookup → + `sys_organization` and resolves its value as a **natural key**, probing + `sys_organization.name`. That misses, and a missed reference is dropped rather + than kept, taking the tenant attribution with it. The `id` fallback probe cannot + rescue it either: under replay every probe is AND-scoped with `organization_id = +`, and `sys_organization` — being the tenant table itself — carries + no such column, so that probe matches nothing by construction. + + What hid it for so long is the **id shape**. `looksLikeInternalId` recognises + UUID and Mongo ObjectId and short-circuits resolution for both, so any fixture + that minted UUID organization ids passed. Every organization better-auth + actually creates is `org_` — including the default organization + `ensureDefaultOrganization` bootstraps on first boot — and that shape is not + recognised. The defect therefore fired on real deployments and on nothing else. + + The loader now remembers that it wrote the stamp itself and skips resolution for + that one field. A seed that authors `organization_id` explicitly still goes + through resolution, so naming an organization by its natural key keeps working. + + Reported by `apps/ee-tenant-crm-showcase` in the cloud repo, which reproduces + the whole path end-to-end: two organizations over one database, each replaying + the artifact's seed datasets into its own private copy. + +- ad5fe25: fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless + + `field.zod` defines `user` as "a lookup specialized to the `sys_user` system + object … target fixed to the `sys_user` system object", and `Field.user()` — + unlike `Field.lookup(reference, …)` — takes no target argument and writes + `reference: 'sys_user'` itself. The target is a constant of the type. + + Two callers read `field.reference` raw and so disagreed: the protocol's expand + gate refused `?expand=` with `400 INVALID_FIELD … declares no +target object`, and objectql's expand loop skipped it. Metadata authored without + the redundant `reference` — hand-written JSON, an AI author, a Studio form — was + read as under-specified when it was complete. Live capture (cloud#983): an + AI-built app's very first screen rendered an error page over that 400. + + New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of + "what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the + set those same two callers already share for "is this a reference at all"). Both + halves of the expand path read it, so the gate can no longer refuse a field the + engine would have expanded, nor bless one it skips. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [0800433] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [85a966f] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [459f925] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [8e53e5d] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [4b945fc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/lint@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 581a96d59d..9f41d6e84a 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.1", + "version": "17.0.0-rc.2", "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 11e5cbb999..2e7fc6ad87 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,318 @@ # @objectstack/metadata +## 17.0.0-rc.2 + +### Patch Changes + +- c4ab50b: fix(metadata): `sys_metadata` 的 DDL 失败不再被静默吞掉 —— 只有「表已存在」这一种原因可以静音 (#4728) + + `DatabaseLoader.ensureSchema()` 过去用一个空 `catch` 吞掉 **全部** DDL 失败,并且照样把 + `schemaReady` 置为 `true`: + + ```ts + } catch { + // If syncSchema fails (e.g. table already exists), mark ready and continue + this.schemaReady = true; + } + ``` + + 注释里的免责理由只覆盖了失败原因中最良性的一种,却用它为**所有**原因开脱。真实的失败 + (权限不足、数据源根本没连上、列类型冲突)之后,表或新列压根不存在,而进程的状态与成功 + 路径**逐字节相同**,启动日志里一行痕迹都没有 —— 这正是 #4420 的形态:声称已持久化、实 + 际没落盘、系统看起来完全健康。#4632 把它定成规则(AGENTS.md → "Degradation log levels"), + 机械检查 `pnpm check:durability-log-level` 已经能发现这一处。 + + 现在按**错误类型**判别,而不是按注释里的乐观假设: + + - **良性的「已存在」**(SQLite 的 `table … already exists` / `duplicate column name`、 + Postgres 的 SQLSTATE `42P07`/`42701`/`42710`、MySQL 的 `ER_TABLE_EXISTS_ERROR` 等及其 + `errno`,并跟随 `cause` 链)—— 表确实已就绪,当作 no-op 静默通过,并照常执行后续的 + `project_id → environment_id` 迁移与 ADR-0005 索引。 + - **其余一切失败** —— 以 `console.error` 上报,文案同时说清**后果**(`sys_metadata` 的表/ + 列未创建,后续每一次元数据写入都会报错、或在宽松驱动上悄悄丢列,而服务器仍报告健康) + 与**修复动作**(修掉下面那条驱动/数据源错误后重启)。只说**一次**,不是每次写入都刷屏。 + - `schemaReady` **不再**在真实失败后置 `true`。启动依旧不被阻断(该方法不抛),但 loader + 不再声称一个它并不具备的就绪状态,下一次元数据操作会重试 —— 数据源只是还在连接这类瞬 + 时故障因此可以自愈,恢复时补一条 `info`。 + + `ensureHistorySchema()` 按同一规则对齐:良性「已存在」不再每次写入都打一条 `error`(过度 + 使用 `error` 是镜像失败),真实失败则同样只响亮一次并保持重试。 + + 无 API / schema 变更;新增内部工具 `isSchemaAlreadyExistsError()`(未从包入口导出)。 + `scripts/durability-degradation.baseline.json` 中指向本单的条目随之删除(该文件 shrink-only)。 + +- 3c7bcc0: feat(spec)!: converge the 11 contracts-vs-domain dual-source type names (#4538) + + `packages/spec/src/contracts/` hand-wrote parameter/result interfaces whose + names collided with same-named zod-derived types in the domains — the #4411 + trap, tracked as 11 rows of `dual-source-exports.baseline.json`. Each name was + judged individually against a three-repo import-level scan (framework, cloud, + objectui): which declaration actually flows at runtime decides the direction. + All 11 rows are deleted from the baseline; no name below is exported twice + anymore. + + **Converged — `./contracts` now re-exports the domain zod type (same + declaration on both entries, imports keep compiling from either):** + + - `NotificationChannel` → `system/notification.zod`'s + `z.infer` (member sets were identical). + - `ValidationResult` → `kernel/plugin-validator.zod` (shapes were identical). + - `HealthStatus` → `kernel/startup-orchestrator.zod` (`details` narrows + `Record` → `Record`). + - `PluginStartupResult` → `kernel/startup-orchestrator.zod`. FROM `plugin: +Plugin` (live object) and `error?: Error` TO the serializable projection + (`plugin: { name, version? }`-passthrough, `error?: { name, message, +stack?, code? }`). Neither side had any consumer outside spec; the + zod-validatable shape wins. + - `StartupOptions` → `kernel/startup-orchestrator.zod` — the PARSED tier + (defaults applied). `IStartupOrchestrator.orchestrateStartup` now takes + `StartupOptionsInput` (the caller-authored all-optional tier, also + re-exported from `./contracts`). Fix for callers typed to the old + all-optional `StartupOptions`: rename to `StartupOptionsInput`. + - `JobExecution` → `system/job.zod`. The system schema's `duration` field is + RENAMED `durationMs` — that is what every job adapter produces and what the + `sys_job_run.duration_ms` column round-trips; the schema described records + nothing ever wrote. Fix: `duration` → `durationMs` when parsing + `JobExecutionSchema` payloads. + - `AnalyticsQuery` → `data/analytics.zod`. The domain schema aligned to the + contract's semantics first: `timezone` LOST its `.default('UTC')` — absence + is meaningful (the engine resolves org timezone, #1982/#2018; the + `/analytics` entry always refused to apply that default). The schema is now + transform-free, so `AnalyticsQuery` ≡ `AnalyticsQueryInput` (both kept + exported). Fix for code that relied on `.parse()` injecting `timezone: +'UTC'`: pass the timezone explicitly or resolve it via the engine chain + (`selection.timezone ?? context.timezone ?? 'UTC'`). + + **Renamed — two genuinely different concepts were sharing one name (both + flow at runtime):** + + - `./contracts` `DriverCapabilities` → **`AnalyticsDriverCapabilities`** + (`{ nativeSql, objectqlAggregate, inMemory }`, the analytics strategy-chain + execution-path probe). The `DriverCapabilities` name now belongs solely to + the data domain's driver feature-flag record (`DriverCapabilitiesSchema`, + what `IDataDriver.supports` declares). Fix: importers of the trio from + `@objectstack/spec/contracts` (or `@objectstack/service-analytics`, whose + re-export is renamed in lockstep) rename the import; importers who meant + the driver flags import `DriverCapabilities` from `@objectstack/spec/data`. + + **Removed — the domain-side declaration was dead (zero import-level consumers + in framework/cloud/objectui; the #4411 family's last survivors):** + + - `system` `MetadataExportOptionsSchema` / `MetadataExportOptions` and + `MetadataImportOptionsSchema` / `MetadataImportOptions` (the + `output`/`source`-directory bags). The names now have ONE declaration each: + the `IMetadataService.exportMetadata` / `importMetadata` parameter + interfaces on `./contracts` (`types`/`namespaces`/`format` and + `conflictResolution`/`validate`/`dryRun`), which `MetadataManager` + implements. No tombstone/D2 conversion, deliberately — these are runtime + option-bag types, not authorable metadata (same reasoning as #4458). + `@objectstack/metadata` re-exports the two names from `./contracts` now + (it previously re-exported the dead system-side shapes its own manager + did not accept). + - `system` `JobSchedule` (the `= Schedule` back-compat alias). The name's one + declaration is the `IJobService.schedule` boundary shape on `./contracts` + (plain-string cron `expression`); the authored metadata type keeps its real + name `Schedule`. Fix: `import type { JobSchedule } from +'@objectstack/spec/system'` → `Schedule` (authoring tier) or the + `./contracts` `JobSchedule` (service boundary), whichever you meant. + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- f78dd83: fix(metadata,client): `subscribeMetadata` callbacks receive real `MetadataEvent`s — the producer now fulfils the declared contract (#4602) + + `@objectstack/spec/api`'s `MetadataEvent` declares top-level `id` (uuid, + required), `metadataType`, `name`, `definition?`, `userId?` — and after + #4587's convergence it is the **only** declared contract for realtime + metadata-change events. But the producer (`MetadataManager`) published a raw + `RealtimeEventPayload` envelope with everything nested under `payload` and no + `id`/`userId`, while the client SDK force-cast that envelope into the callback + (`callback(event as any as MetadataEvent)`). Subscribers who wrote + `event.name` / `event.metadataType` — exactly what the types promised — + compiled green and read `undefined` at runtime. + + Producer now fulfils the contract: + + - `MetadataManager.register()` / `unregister()` build a true `MetadataEvent` + (generated uuid `id`, flattened top-level fields, `userId` when the write + declares an actor) and validate it with `MetadataEventSchema.parse` before + publishing. The transport envelope is unchanged (`RealtimeEventPayload`, + with `payload` carrying the complete `MetadataEvent`). + - A `register()` **overwrite now publishes `metadata.{type}.updated`** instead + of a second `.created`, mirroring the existing `added`/`changed` watcher + split. Previously `.updated` was declared with no producer at all. + - `MetadataEventType` is a closed enum: metadata types outside it (e.g. + `translation`) have no declared realtime event, so nothing is published for + them (debug-logged) instead of emitting an event every schema-compliant + consumer must reject. + + Consumer validates instead of casting: + + - `@objectstack/client`'s `subscribeMetadata` (and therefore + `@objectstack/client-react`'s metadata hooks, which delegate to it) unwraps + the envelope and runs `MetadataEventSchema.safeParse` at the boundary. An + off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as MetadataEvent` + double-cast is gone. + + New seam: `MetadataWriteOptions.userId` (`@objectstack/spec/contracts`) lets + write paths that know the acting user carry it into the published event's + `userId`. Existing callers are unaffected — the field is optional and absence + means "no human actor". + +- beefe89: fix(metadata): 历史序号 `event_seq` 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4825) + + `DatabaseLoader.nextEventSeq()` 过去把读 `sys_metadata_history` 的**全部**失败折成同一个答案: + + ```ts + } catch { + // Table not provisioned yet or driver error — start at 1. + return 1; + } + ``` + + 注释同时点名了两种原因,然后用同一个 `return 1` 对待。这是 #4728 刚修掉的同一种形状,但危害是 + **更贵的那一半**:#4728 是「字节没落盘」,本条是「**落盘的字节是错的**」。历史表里已经有 N 行时, + 一次瞬时读失败(连接抖动、超时、权限)会让下一条历史拿到 `event_seq = 1`,与既有行**直接撞号**, + 而 insert **成功**、日志**一行没有**。`event_seq` 正是历史列表排序与 rollback 定位的依据,撞号之后 + 版本顺序就永久不可信 —— 重试不修、重启也不修。 + + 现在按**错误类型**判别,复用 #4728 落地的那套判别机制(`packages/metadata/src/utils/schema-sync-errors.ts` + 里新增的 `isMissingTableError()` 与既有 `isSchemaAlreadyExistsError()` 共用同一个 code / errno / + message + `cause` 链匹配器,而不是在同一个包里另起一套错误判别): + + - **良性的「表还没建」**(SQLite `no such table: …`、Postgres SQLSTATE `42P01` / + `relation "…" does not exist`、MySQL `ER_NO_SUCH_TABLE` / errno `1146`,并跟随 `cause` 链)—— + 没有行,就没有可撞的号,`1` 确实是下一个号,静默返回。 + - **其余一切读失败** —— `nextEventSeq()` 原样抛出。调用方 `createHistoryRecord()` 以 + `console.error` 上报**后果**(该条历史记录未写入;元数据写入本身已成功,所以服务器仍报告健康, + 而变更历史正在悄悄出现空洞,版本时间线与 rollback 目标将不完整)、**为什么是空洞而不是错号** + (从 1 发号会与既有行撞号,把「不完整」变成「顺序错误」,后者无人能发现)与**修复动作**, + 然后**跳过这条历史记录**。 + - 判别的方向刻意保守:凡是没有被正面识别为「表不存在」的,一律当作真实失败。`does not exist` + 本身不够 —— `role "…" does not exist`、`database "…" does not exist`、`column "…" does not exist` + 都是真实失败,对着一张可能满是行的表返回 1 正是要避免的事,所以消息匹配要求 table/relation 与 + 该短语同现。 + + 两条边界保持不变:元数据写入本身**不**因此失败(记录已经落盘,把它报成失败是比原缺陷更糟的谎), + 以及本路径已知的并发撞号限制(非事务,canonical producer 仍是 `SysMetadataRepository`)——那是被 + 记录过的限制,与「读失败静默重置到 1」是两回事。报告只说**一次**,恢复时补一条 `info`。 + + 无 API / schema 变更;新增内部工具 `isMissingTableError()`(未从包入口导出)。 + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + - @objectstack/metadata-fs@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 29892a38fc..e287ed1891 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 303582e03b..3ef4912954 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1097 @@ # @objectstack/objectql +## 17.0.0-rc.2 + +### Major Changes + +- ec975f1: fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419) + + `findOne` reads a single row, which makes its predicate the only thing between + the caller and _an arbitrary record_. When the predicate is missing the result is + not `null` — it is the object's **first row**: a real, plausible-looking record + with nothing to do with the request, which the `if (!row)` check every call site + already has cannot catch, and which then propagates into whatever is computed + next. Reported downstream: line items defaulting their price from the first + product in the catalog rather than the selected one, and "is this deal already + closed?" answered against an unrelated record while the write that followed + correctly targeted the intended id. A throw would have been caught in + development; a `null` would have been caught by the null-check. A valid-looking + wrong record defeats both. + + **Breaking — `findOne` now refuses a query that selects nothing in particular.** + + FROM → TO: + + | Was | Now write | Meaning | + | ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------ | + | `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | + | | `findOne(o, { search: 'Acme' })` | the record this search finds | + | | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | + | | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site | + + One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest + one", or switch to `find(o, { limit: 1 })` if any row will do. The error names + all four. `find` and `count` are unchanged — returning or counting every row is + an honest answer; only `findOne`'s implicit "just one of them" turns a missing + predicate into a confidently wrong record. The guard reads the CALLER's + predicate, before RLS/sharing middleware injects its own: a tenant filter + narrows which rows are visible, it does not make "whichever comes first" + something the caller asked for. + + **Two silent drops that produced the same wrong record are fixed with it.** + + - **`findOne({ search })` applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion lived inline in `find` and nowhere else, + while `find` and `findOne` are checked against the SAME legal-key set — so + `search` passed the gate, rode onto the AST, and reached a driver. No driver + reads `ast.search`. The read therefore ran with no predicate at all and + `limit: 1` did the rest. The expansion is now one method both call. + - **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It + translated `query.where` and dropped the rest, so `findOne({ orderBy })` did + not return the newest record — it returned whichever document the scan reached + first. `find` and `_findStream` in the same driver had always handled all + three. This one matters beyond Mongo: the guard above tells an unpredicated + caller to reach for `orderBy`, and an escape hatch one backend ignores is not + an escape hatch. No ordering is IMPOSED when the caller supplies none — both + drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo + "never sorting" is corrected, since it cited the dropped parameter as + agreement. + + **And a gate so the class does not come back.** A drift pin walks + `ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an + observable effect — on the AST the driver receives, on the driver options, or in + an explicit "not executed, and here is why" entry (only `limit`, which the + contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through + two rounds of hardening because nothing asked that question. + + Together with #4346 (`filter` → `where` folds on every entry point) and #4400 + (unknown option keys throw), a read parameter the engine does not execute now + fails at the call site instead of quietly changing the answer. + +- cb5a75e: feat(objectql)!: a hook `condition` the platform cannot evaluate now ABORTS the operation (#4775) + + **Breaking.** A declarative hook whose `condition` cannot be evaluated used to + emit a `logger.warn` and `return false` — the hook simply did not fire. Existing + hooks that have been getting by on that silent skip will now **fail the write**. + That is the point of the change, not a side effect: those conditions were never + enforcing anything, and the failure is how you find out. + + ## What changed + + "The condition said no" and "the platform could not work out what the condition + says" used to collapse into one outcome, and that one outcome carries **opposite** + risks depending on the hook: + + - a `before*` guard ("hold this write when the condition is met") swallowed into + `false` **lets through** a write it was declared to stop; + - an `after*` audit ("leave a trace when the condition is met") swallowed into + `false` **drops** a row nobody will go looking for, because nobody knows it + should exist. + + So an unevaluable condition is `declared ≠ enforced`, and it is now resolved the + way #4649 already resolved it for validation predicates one module over: reject + loudly, naming the hook and the key that would not resolve. The rejection is a + `HookConditionError` (exported), carrying `hook` / `object` / `event` / + `condition` / `reason` / `fault` / `missingKey` machine-readably. + + `before*` and `after*` take the **same** direction, knowingly: a typo in an + `afterUpdate` audit condition fails the write it was only watching. One rule, one + answer — the platform does not grow a hidden second rule that makes the failure + direction depend on the event name. + + A condition that never **compiled** aborts too. Its old treatment + (`condition ignored`) was the worse half of the swallow: the gate disappeared + entirely, so a declared guard let every write through and an audit fired on all + of them. It is reported at invocation rather than at bind time, so one broken + hook cannot wedge boot for an app nobody is writing to. + + ## What did NOT change + + - A condition that evaluates **FALSE** is still just a skip, and the write still + succeeds. Only _unevaluable_ is new. + - `onError` (`abort` / `log`) is untouched and is deliberately **not** in this + path. It governs a handler that threw; the condition gate runs before the + handler is ever reached. Routing a condition fault through it would let + `onError: 'log'` resurrect the exact silent skip this change abolishes, and + would mint a third set of semantics for one word. `retryPolicy` and `async` + are outside it for the same reason. + + ## Predicate (`multi: true`) bulk writes (#4800) + + A bulk write matches N rows and fires the hook **once**, so `previous` is unbound + and `record` is the bare payload — there is no single prior record, and + materialising declared fields to `null` would state something false about all N. + Fail loud takes **no exception** here, but the message is a diagnosis rather than + a riddle: it names the hook, says _this is a predicate bulk write and there is no + single prior record_, and gives the route that works (rewrite without `previous`, + or target the write at one record by id). + + It deliberately does **not** offer "use a record-change flow trigger instead": + that trigger subscribes to these same lifecycle hooks, so on a bulk write it + fires once with `previous` undefined too — verified against + `trigger-record-change` and the engine, not assumed. Pointing at it would have + made this very message the next `declared ≠ delivered`. + + An **undeclared** key on a bulk write still gets the ordinary typo message — that + one really is a misspelling, and calling it a batch problem would send the author + to fix a field that is spelled correctly. + + ## Migrating + + Run your app and watch for `HookConditionError`. Each one names the hook and the + key. The usual causes, in order of frequency: + + - **a misspelled or retired field** — fix the condition, or declare the field; + - **an unguarded `null` comparison** (`record.spent > record.budget`) — guard + with `!= null`. Note `has(x)` does **not** do this: a declared field holding + `null` is still PRESENT, so `has(x)` is `true` and the ordering comparison + still faults; + - **`previous` on a bulk write** — rewrite without `previous`, or write by id; + - **a bare identifier** (`done == true`) — hook conditions are `record`-scoped, + so write `record.done == true`. Flow/automation conditions, which flatten + fields to top level, are a different surface and are unaffected. + +### Minor Changes + +- ce5242c: feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586) + + better-auth owns every write to the identity tables (`sys_member`, `sys_user`, + `sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on + purpose** — the route already authorized the action under better-auth's own ACL, + and ADR-0092 D2 refuses user-context writes to those tables outright. The + consequence was that the human who clicked _make admin_ was known exactly once, + in the hook layer where the session exists, and then discarded: every + `trackHistory` transition on `sys_member` recorded `user_id: null` / "system", + and `sys_user_permission_set.granted_by` was written null by the auto-grant. + "Who made this person an org admin?" had no answer in the platform's own audit + log. + + **What changed** + + A request-scoped attribution seam, general rather than a `sys_member` special + case: + + | Layer | Before | After | + | :--------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | + | `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED | + | `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` | + | better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open | + | audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody | + | `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row | + + Outside a request scope nothing changes: writes stay bare `{ isSystem: true }` + and audit rows keep recording `null`. Absence is still never upgraded into a + caller, and never written as a sentinel string (ADR-0118 D1/D2). + + **Hard constraint — attribution is not authority** + + `attributedUserId` is read by exactly one consumer, the audit writer, and by no + security middleware. It never becomes `ExecutionContext.userId`, so it is never + the subject the engine authorizes as: not RLS `current_user`, not the ownership + stamp, not permission resolution. A context carrying only `attributedUserId` + authorizes exactly like an empty context (ANONYMOUS), and a context carrying it + beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing + identity writes as the human would re-adjudicate a decision better-auth already + made — the second adjudication track ADR-0095 D3 closed. The constraint is + pinned by tests at three layers: the engine seam + (`packages/objectql/src/engine.test.ts`), the better-auth adapter + (`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the + live HTTP route (a plain member still cannot promote themselves). + + **For authors and plugin developers** + + `attributedUserId` is authorable on `ExecutionContext` and readable as + `ctx.provenance?.attributedUserId` in hooks. Use it to answer _who is + responsible_; keep using `ctx.session` / `ctx.user` to decide _what is + permitted_. The two are separate fields precisely so the distinction cannot be + blurred by accident. + +- 257d97a: ADR-0078 Phase 4, decided rather than deferred: the silent skips stop being silent at runtime. The registry — the one choke point every metadata door goes through — now emits a functional-completeness diagnostic at registration, and the webhook enqueuer's zero-trigger skip warns instead of returning `null` wordlessly. + + **The Phase 4 ruling.** The phase had two halves, and they got opposite verdicts: + + - **Generative rule sweep: rejected — not deferred.** A generator can enumerate candidates ("which optional keys might be load-bearing?") but cannot verify runtime skip sites, and a rule without its skip-site citation is a false prescription — this campaign shipped four of those and every one was caught by the verification pass a generator would skip. The route is structurally wrong; no amount of waiting produces the evidence that would fix it. + - **Registration-time diagnostics: built now.** The evidence was already in hand, not pending: #3896 (Setup authoring inserted `sys_sharing_rule` rows directly, bypassing the schema that "required" `criteria`) and cloud's `rowColor.mapping` (an `as never` cast bypassed tsc) prove that doors which skip Zod and lint are real. The author-time gate only protects metadata that passes through `os build` / `validate` / `lint`; `SchemaRegistry.registerObject` is where _every_ door converges — declared stacks, plugin objects, `extend` contributions, `saveMetaItem`, raw `registerObject` calls. + + **Same predicate, same rule ids, different posture.** The registry calls the same `checkFieldCompleteness` that `validate-functional-completeness` uses, so the boot log carries the _same rule ids_ the lint reports (`field/summary-without-operations`, …) — an operator or an AI reading the log greps the id straight into the same docs and suppression story. But the registry **warns and never throws**: ADR-0078 §1's error severity means _the instance is dead_, not _the system is dead_ — an inert field must not kill a boot that thousands of healthy objects share. Errors block at author time; the registry's job is to make sure the silence never survives to runtime unobserved. + + One line per object with every finding aggregated (not per request — the hot path stays free; not per finding — a three-dead-field object is one greppable line). Follows `warnStrippedLegacyApiMethods` (#3543) exactly: module-level once-per-object dedup, injectable `warn`, pure observation that never mutates the schema. + + **The webhook skip now names itself.** `auto-enqueuer.ts`'s `if (triggers.size === 0) return null` sat under a comment blessing the empty case as "a manual-only webhook" — a mode #3196 removed (no manual fire path exists). The skip now warns with the author-time rule id (`webhook/without-triggers`), and the comment tells the truth. Only _active_ rows reach the parse (`where: { active: true }` — verified, not assumed), so a deliberately disabled webhook stays warning-free. + + **Scope honesty:** field rules and the webhook rule get the runtime twin. `view/layout-without-binding` stays author-time-only — views don't register through this choke point and the renderer half of the evidence lives in objectui. + + Tracked in #4544. This closes the ADR-0078 loop end to end: author-time error, runtime warning, one shared predicate deciding both. + +- 20b1a9e: fix(data): the audit anchor is engine-owned, and a lookup must resolve (#4447, #4441) + + Two write-path contract holes from the v17 verification sweep. + + **#4447 — `created_at` was client-writable on an ordinary PATCH.** Its two + siblings only looked protected: the audit hook force-advances `updated_at` / + `updated_by` on every update, so a forged value is overwritten. `created_at` is + insert-only, so nothing overwrote it. The root cause is a _declared_ audit + field shadowing the platform's: `applySystemFields` skips its injection when + the object already carries the name, and the merge lets the declared one win — + correct for an authored business field, wrong for the audit family. A built app + artifact ships a materialized `created_at` carrying only FieldSchema defaults + (`readonly: false`), which shadowed the engine-owned definition, so the + readonly strip had nothing to key off. The audit family's **governance** + (`readonly` / `system` / `type` / `reference`) is now forced by the platform + while presentation (label, description, hidden, group …) stays the author's. + Back-dating is unaffected: `preserveAudit` (#3479/#3493) and `isSystem` writes + still reinstate the original timeline. The strip now also reports through + `droppedFields`, giving the #3794 contract its first live producer on this axis. + + **#4441 — a `lookup` accepted an id that exists in no row of its target.** + Including `sys_position_permission_set.permission_set_id`, where a dangling row + is a security-surface record that resolves to nothing and the audience-anchor + gate has to resolve that very set to evaluate the grant. Writes are now refused + with `400 VALIDATION_FAILED` and a `fields[]` entry + (`code: 'reference_not_found'`, naming the field, the target and the + unresolvable id) — the catalogued `FieldErrorCode` that had no emitter until + now, with its message in the four platform locales. + + Scope for #4441 is deliberately narrow: caller-supplied keys only (so server + stamps are never reported as the caller's bad reference), non-system writes only + (seed replay and package install keep their ordering freedom), empty means "no + link", and it fails OPEN when the target cannot be checked. The existence probe + is unscoped, because existence is a fact about the database — whether the caller + may create the binding stays the RBAC/RLS layer's decision. + +- f2445c9: feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) + + A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, + which are contracted to resolve an affected row COUNT and nothing else. That + satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / + `changes`, so before #4626 the engine fabricated a per-record event with + `recordId: ''` and `after: ` — an event every schema-compliant consumer + must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into + a real delivery naming an unidentifiable record. #4626 removed the fabrication + and published nothing instead: honest, but it left webhooks, knowledge sync and + `subscribeData` silent for every predicate write. + + Bulk writes now get their **own** contract rather than impersonating a + per-record one or going dark: + + - **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from + `DataEvent`, not a widened one: a consumer that receives + `data.records.updated` knows from the type alone that no `recordId` is + coming, instead of discovering an empty string at runtime. + - **Engine** publishes it from the `multi: true` branches of `update()` / + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A + predicate that matched **zero** rows publishes nothing (no data changed — this + is what keeps an idle background sweep from becoming an hourly "0 records" + delivery), and a driver that resolves a non-count publishes nothing and warns + rather than asserting a number it cannot verify. Per-record writes are + untouched, including a scalar `where.id` with `multi: true`, which is still a + single-record target and still emits `data.record.deleted`. + - **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are + **not** extra sources for `create` / `update` / `delete`: the delivered body + has no `recordId` and no record, so routing it to existing per-record + subscribers would hand them a payload missing every field they read — the + same class of breakage as the old `recordId: ''`, from the other direction. A + webhook that wants both subscribes to both. Bulk deliveries dedup on the + producer's event uuid, since two sweeps in the same millisecond are genuinely + different events that a timestamp-based key would collapse. + - **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the + same loud boundary validation as `subscribeData`. Kept a separate method for + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => +void` callback would recreate exactly the "typed field, `undefined` at + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened + from `data.` to `data.record.`, so an aggregate event is ignored rather than + rejected as off-contract. + - **Knowledge sync** now says out loud that a predicate write leaves its index + stale. A knowledge index is a per-record projection and `matched: 40` names no + record, so no event shape could drive it — the durable fix is reconciliation, + tracked in #4672. + + The event carries no `where` predicate. The only one available at publish time + is the middleware-composed AST, whose filter embeds the security layer's + injected row scoping (RLS, sharing) — publishing it would ship tenant scoping + internals to whatever external URL a webhook points at. + + Also pays off a measurement debt from #4655, which claimed the write-path cost + of event publishing had been measured but never published the numbers: + `packages/objectql/src/engine-data-events.bench.ts` measures it. Against an + in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, + single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless + of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its + relative cost shrinks as the match set grows. + +- 462b713: fix(objectql,client): `subscribeData` callbacks receive real `DataEvent`s — the producer now fulfils the declared contract (#4626) + + `@objectstack/spec/api`'s `DataEvent` declares top-level `id` (uuid, + required), `type`, `object`, `recordId` (required), `changes?`, `before?`, + `after?`, `userId?`, `timestamp`. But the producer (the ObjectQL engine) + published a raw `RealtimeEventPayload` envelope with `{ recordId, after, +changes }` nested under `payload` and never generated `id`/`userId`, while the + client SDK force-cast that envelope into the callback (`callback(event as any +as DataEvent)`). Subscribers who wrote `event.recordId` / `event.changes` — + exactly what the types promised — compiled green and read `undefined` at + runtime. The data-side twin of #4602. + + Producer now fulfils the contract: + + - `ObjectQL.insert()` / `update()` / `delete()` build a true `DataEvent` + (generated uuid `id`, flattened top-level fields, `userId` from the + execution context when the write names an actor) and validate it with + `DataEventSchema.parse` before publishing. The transport envelope is + unchanged (`RealtimeEventPayload`, with `payload` carrying the complete + `DataEvent`), so subscribers keep receiving `{ type, object, payload, +timestamp }` on the wire. + - A batch insert publishes one event **per record** (as before), each with its + own event id. + - **A multi-row write (`multi: true` → `updateMany` / `deleteMany`) now + publishes nothing.** Those driver methods return only an affected count, so + there is no record for a required `recordId` to name; the engine logs a + warning naming the gap instead of publishing the previous fabrication + (`recordId: ''`, `after: `), which every schema-compliant + consumer had to reject. **Consequence: webhooks and knowledge sync no longer + fire for bulk writes** — they previously fired once with an unusable body. A + real bulk event contract is tracked in #4639. + + Consumers validate or read the fulfilled shape instead of guessing: + + - `@objectstack/client`'s `subscribeData` (and therefore + `@objectstack/client-react`'s `useDataSubscription` / + `useDataSubscriptionCallback` / `useAutoRefresh`, which delegate to it) + unwraps the envelope and runs `DataEventSchema.safeParse` at the boundary. + An off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as DataEvent` + double-cast is gone, and the `recordId` option now filters on the fulfilled + event. + - `@objectstack/plugin-webhooks`' auto-enqueuer reads the required + `recordId` directly; its `recordId ?? id ?? after?.id ?? before?.id ?? +'unknown'` fallback chain is gone, and an off-contract event is dropped with + a warning rather than delivered under the literal id `'unknown'`. Delivered + webhook bodies now also carry the event's `id`/`type`/`userId`; the record + itself stays nested under `after` and the envelope keys (`object`, + `recordId`, `action`, `timestamp`) still win. + - `@objectstack/service-knowledge`'s event sync reads the record from `after` + (create/update) and the id from `recordId` (delete) for `data.record.*`. + It previously indexed the envelope itself as if it were the row, and never + resolved an id for deletes. + +- 63b33e6: A `datasourceMapping` rule is routing, not a hint — an object mapped to an + unreachable datasource no longer silently reads and writes the DEFAULT store + (#4462). + + **Observable behavior change; read this before upgrading.** Measured on `main` + during the v17 verification: map an object to a Postgres datasource with a bad + URL and the boot succeeds, `/ready` answers `200`, the datasource name appears in + **zero** log lines, `POST /api/v1/data/` returns `201` — and the + row is physically in the default store. The operator finds out by opening the + database they declared and finding it empty. ADR-0062 D2's phase-1 note called a + mapping-only datasource "decorative" to keep an example byte-for-byte unchanged; + what that bought was a silent data-placement bug. + + The fix is a pair, and each half is what makes the other correct: + + 1. **Routing stops falling through** (`@objectstack/objectql`). `getDriver` step + 2: a mapping rule that MATCHES and names a datasource with no live driver now + throws — `DatasourceUnavailableError` when the connect layer recorded a + verdict, otherwise an error naming the object, the datasource and the two + remedies. `default` still resolves onward: the default driver keeps its + natural name (#3826), so step 5 is how routing to it works. + 2. **ADR-0062 D2 grows gate (d)** (`@objectstack/service-datasource`, + `@objectstack/runtime`). A datasource a mapping rule routes at least one + object to is auto-connected at boot, and a boot-time connect failure is + **fatal** with an operator-readable reason — the same call gate (b) already + makes for an explicit `object.datasource` binding, now correct for (d) + because half 1 removed the fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still + degrades the boot instead, as for every other fatal connect. + + The mapped-object list is resolved by the boot path from the engine's own + matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) and passed to + `connectDeclared({ mappedObjects })`; the connection service never re-derives + rule matching. Two matchers drifting by one clause would connect a datasource + routing never uses, or route to one nothing connects — the defect again. + + **What to do if this breaks your boot.** It means a `datasourceMapping` rule in + your stack points at a datasource that cannot be connected. Either fix the + datasource configuration, or delete the rule — the second is what + `examples/app-crm` did in this change, and it is what keeps that example's + runtime behavior identical: its rules routed everything to an unconnected + `:memory:` datasource, i.e. to the default store by fall-through. + +- ce92674: feat(email): declared email templates reach the mail service (#4509) + + Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate` + resolves `(name, locale)` against **`sys_email_template` rows**, and the only + writers of those rows were the built-in auth templates plus a code-constructed + `EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every + door an author can actually use — a stack's `emailTemplates:`, an + `*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked + items in a metadata store nothing read back. So an admin could "fix" the + password-reset email in Studio, get a success toast, and watch users keep + receiving the built-in copy: ADR-0078 false compliance on **authentication + mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049 + enforce-or-remove, route: enforce). + + **`bootstrapDeclaredEmailTemplates`** now materializes declared templates into + `sys_email_template` at boot. Each item is validated through + `EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real + consumer, defaults and all — and projected with `mapTemplateToRow`, which is the + **same** mapping the built-in seeder uses, extracted and shared so the two doors + cannot drift apart. A malformed template warns and is skipped rather than + crashing boot. + + **Runtime writes take effect immediately.** Unlike `webhook`, `email_template` + is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio + save inert until the next restart — the same bug, half-fixed. The plugin also + subscribes to `email_template` metadata changes and re-materializes the single + changed item; withdrawing a template deactivates its rows (across locales) + rather than deleting them. + + **Three breaks sat on this path, not one**, and closing any two of them would + still have shipped a template that never sent: + + - `@objectstack/objectql` never registered a manifest's `emailTemplates:` into + the metadata registry at all — the key was simply missing from the generic + ingestion list, so the bridge's own source was empty. + - The built-in seeder left `managed_by` at the column's `'admin'` default, which + made platform templates masquerade as admin-authored. Since the bridge refuses + to overwrite admin rows, a built-in would have permanently outranked the + template an app declared. Built-ins now stamp `managed_by: 'platform'`. + - Nothing materialized declared metadata into rows. + + **Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule` + (#2909): `sys_email_template` gains `managed_by` / `customized`. Declared + templates re-seed every boot as `managed_by: 'package'`; a row an admin created + (`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never + overwritten, so reworded transactional mail survives redeploys. This is a + separate axis from `is_system`, which keeps its existing meaning for built-ins. + + The `email_template` liveness ledger flips from 13 dead properties to fully + live, with an ADR-0054 runtime proof bound on `subject` + (`email-template-materialization`): it boots a real stack, authors a template + that overrides a built-in auth template, and asserts the **authored** wording is + what reaches the transport. + +- eb4204b: feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + + The `script` executor's contract — _the named function returns a value; data I/O + stays on the flow graph_ — existed only as a comment inside the executor, while + #4354's run summary depended on it. That summary reports no record metrics for a + `script` step precisely because a pure function's writes are downstream + `create_record` / `update_record` nodes counting themselves. A function that + wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable + from the broken sweep the counters exist to detect, recorded permanently on + `sys_automation_run`. + + **The rule is now visible.** `ActionDescriptor` carries + `handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes + `'pure'`, so the action catalog, the designer palette and the reference docs + state the rule an author has to follow instead of an executor holding it + privately. + + **And a legitimate writer can opt out honestly.** A `defineStack({ functions })` + entry may declare what it does, in either shape: + + ```ts + defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: "writes" }, // declared writer + }, + }); + ``` + + A step calling a declared writer reports `unmeasuredEffect`, so the run's + `unmeasured` tally keeps the broken-sweep query + (`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that + flow. Marking _every_ `script` step unmeasured was rejected: it would blind the + detector on every flow that calls any function in order to cover the few that + break the rule. + + Nothing here is retired or renamed: a bare `functions: { fn }` entry is + unchanged and means `effect: 'pure'`. The declaration is carried end to end — + `ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the + existing `packageId` string and exposes `resolveFunctionEntry(name)`, + `objectstack build` lowers a declared entry without dropping it, and the + artifact loader re-attaches the module's callable to the declaration the JSON + carried. + + **Also fixed:** `bindHooksToEngine` returned before registering a bundle's + functions when the stack declared no hooks, so a flow-only app's + `defineStack({ functions })` reached the engine as nothing and every `script` + node calling one failed with "no function named 'x' is registered". + +- 84b6e58: **A declarative hook `condition` is now evaluated against the RECORD — the stored row overlaid with this write's payload — not against the update payload alone (#4770).** + + ⚠️ **Behaviour change — read this before upgrading.** The condition gate used to evaluate + against `ctx.input.data`: only the fields the current write happened to carry. + `ctx.previous` sat behind it, unreachable, and the two were never merged. So a condition + could reference only a field the update _happened_ to touch; referencing anything else + aborted the CEL expression with `No such key` — which the gate swallowed into `false`, + leaving one WARN line as the sole trace. + + For a guard-style hook that reads as "let it through"; for an audit-style hook it reads as + "do not record it". `condition: "record.done == true"` on an audit hook therefore did NOT + run on the most ordinary updates there are — change the status, change the assignee — + because `done` was not in the payload. + + The record a condition reads is now built the same way a validation predicate's is + (#1871 / #4649, via one shared helper so the two cannot drift): + + - **stored ⊕ payload** — the prior record overlaid with this write's data, so a condition + may reference any field of the record, not just the changed ones. The payload still + wins for the fields it carries. + - **total over the object's DECLARED fields** — `null` for a declared field present in + neither, so a driver that stores only the columns it wrote no longer decides whether an + expression is evaluable. + - **declared fields only** — an undeclared or typo'd key (`record.stauts`) stays + unevaluable and is still reported, exactly as before. + + Materialisation happens only when the persisted state is actually in hand — an insert, or + an update whose prior row was fetched. A predicate (`multi: true`) bulk update carries no + prior row, so its payload is left as it is rather than gaining `null`s that would + contradict the stored rows. No code path fetches a record it did not already load. + + **What you may see after upgrading** + + - **Conditions that never fired start firing.** A hook gated on a field the payload rarely + carried was silently skipped; it now evaluates. This is the declaration finally being + honoured, but expect hooks to run on writes where they previously did not. + - **A condition is now about the record's STATE, not about this write's diff.** + `record.done == true` fires on every update of a task that _is_ done, not only on the + update that set it. A condition cannot express a transition today — the CEL scope binds + `record` only. + - **Conditions guarded with `has(...)` need `!= null`.** `has(x)` asks whether the key is + **present**, and a declared field holding `null` is present — so + `has(a) && has(b) && a > b` still faults on `null > null`. Same lesson as #4649: + + ```diff + - condition: 'has(record.spent) && has(record.budget) && record.spent > record.budget' + + condition: 'record.spent != null && record.budget != null && record.spent > record.budget' + ``` + + `has()` remains correct for asking whether an **undeclared** key exists. + + **Unchanged, deliberately:** what happens when a condition is _still_ unevaluable after + merging — it is logged at WARN and treated as `false`, as before. Whether that fallback + should differ by hook category (a guard fails open, an audit fails silent) is a separate + decision, tracked on its own issue. + +- f160ba4: **A declarative hook `condition` can now express a TRANSITION: the CEL scope binds `previous` alongside `record` (#4784).** + + The condition gate evaluated against a single root — `{ record }`. Both published skill + docs, however, taught the `previous` form: `objectstack-formula` §5 ("Update hook + condition — `previous` vs `record`") gives + `P\`previous.status != 'escalated' && record.status == 'escalated'\``, and its legacy +migration table maps `OLD.x`→`previous.x`and`ISCHANGED(x)`→`previous.x != record.x`. +Written into a hook, any of those aborted the expression with `No such key: previous`, +which the gate swallowed into `false` — the hook simply never ran, leaving one WARN line. + Declared ≠ delivered. + + It became load-bearing with #4770. `record` now means the record's **state** (stored ⊕ + payload), so `record.done == true` is true on _every_ update of an already-done row — not + only the one that completed it. `showcase_audit_task_completion`'s own description says + "after a task transitions to done", and there was no way to write that. Now there is: + + ```ts + condition: P`previous.done != true && record.done == true`; + ``` + + `previous` is built exactly as the validation side builds it (#4649), through the shared + `materializeDeclaredFields` helper, so one CEL expression means one thing on both + surfaces: + + - **the stored pre-write row**, made **total over the object's DECLARED fields** — a + column the driver never returned reads as `null` instead of aborting the expression; + - **declared fields only** — `previous.dnoe` stays unevaluable, so a typo is still + reported rather than quietly answered; + - **copied, never mutated in place.** `ctx.previous` is the engine's own pre-image object, + observed by every after-hook; the materialised `null`s do not leak into it. + + **Where `previous` is NOT bound** — verbatim the rule `validation/rule-validator.ts` + already applies, so referencing it there makes the condition unevaluable: + + - **insert events** (`beforeInsert` / `afterInsert`) — there is no prior state. Write + insert conditions over `record` alone. + - **predicate (`multi: true`) bulk updates** — one write matches N rows and the hook fires + once, so there is no single prior record. Binding `{}` or `null` would answer + `previous.x == null` with a fabricated fact about rows nobody read. + + **Cost: none.** No new demand-driven fetch was introduced. `previous` rides on the prior + row `engine.update` already reads whenever an afterUpdate hook is registered — the same + one that feeds `ctx.previous` and record-change flow triggers. A condition that never + mentions `previous` reads nothing extra, pinned by test. + + **What you may see after upgrading:** hooks whose condition referenced `previous` never + fired before and start firing now. That is the declaration finally being honoured — review + any hook carrying a `previous.*` condition before you upgrade. + + **Unchanged, deliberately:** a condition that is _still_ unevaluable is logged at WARN and + treated as `false`. Whether that should fail loudly instead is tracked separately. + +- 127f091: 修复:每个 `os migrate` 子命令关停后,#4551 悬空引用巡检都会把 `sys_metadata` / `sys_view_definition` 报成 `unreadableObjects`(#4747) + + 一条**成功**的命令过去会在返回 JSON 之后打出两行 `ERROR Find operation failed` 和一份 + `unreadableObjects` 非空的巡检报告 —— 对抓 ERROR 的 CI 流水线是直接误报源,更要命的是它把 + `unreadableObjects` 变成了恒为真的告警:那个桶存在的意义正是区分「我没能检查」和「我检查了, + 没问题」,一个每次健康运行都非空的桶不再携带任何信息。 + + 两处静默空转叠出了这个结果: + + - `ObjectQLPlugin` 的关停逻辑写在 `stop()` 里,而内核的插件契约是 `init`/`start`/`destroy` —— + `stop()` 从来没有被任何人调用过,ADR-0057 巡检定时器因此在任何宿主上都不会被解除。改为 + `destroy()`(与 `DefaultDatasourcePlugin` 一致)。 + - `bootSchemaStack().shutdown()` 调的是 `(runtime as any).stop?.()`,而 `Runtime` 根本没有 + `stop` —— 可选调用把「没有关停」伪装成了「关停过了」。改为走内核自己的 `kernel.shutdown()`, + 与 `os serve` 收到 SIGTERM 时同一条路径。 + + 同时 `LifecycleService.stop()` 不再只是清定时器:它还会把「引擎正在拆」这一位交给正在飞行中的 + sweep,巡检据此在读之前停手。因关停而失败的读**不再进入** `unreadableObjects` —— 那不是关于 + 数据源的证据;报告改用新增的 `DanglingReferenceReport.aborted` 记录「这次没跑完」,所以不完整 + 依然是响的,只是不再占用发现桶。 + + **真正读不出来的对象(数据源故障)照旧进 `unreadableObjects`**,巡检在 CLI 场景也照旧运行 —— + 这里没有「一次性命令不跑巡检」的开关,只有「引擎活着才读」的生命周期边界。 + +- 50185a8: **Validation rules now fail CLOSED when their predicate cannot be evaluated, and the record a predicate reads is total over the object's declared fields (#4649).** + + ⚠️ **Behaviour change — read this before upgrading.** A `script` / `cross_field` / + `conditional` validation whose CEL predicate faulted used to be logged at WARN and + **skipped**, so the write went through. The rule stayed declared, appeared in the + metadata and in any "what protects this object" listing, and enforced nothing. Two + changes close that, and they are load-bearing together: + + 1. **The merged record is total on UPDATE, not just on INSERT.** Every field the object + declares is present when the predicate runs — `null` when it is in neither the payload + nor the prior record. Previously `previous` was whatever the driver returned, so on a + driver that stores only written columns a predicate referencing a declared column + aborted with `No such key` and the rule was skipped. The `previous` CEL binding is + materialised the same way. Insert and update now behave identically. + 2. **A predicate that still cannot be evaluated rejects the write** with + `VALIDATION_FAILED`, naming the rule and — when the fault is a missing key — the key + the predicate read and how to fix it. A validation exists to reject a write; "the rule + could not be checked" must never resolve to "allowed". + + `severity` still governs blocking: an unevaluable `warning` / `info` rule is logged and + does not throw. + + **What you may see after upgrading** + + - **Rules that were never running start running.** A rule skipped because of a missing key + now evaluates and can reject writes it previously let through. This is not a regression — + it is the declaration finally being enforced — but on an existing deployment it can + surface as new `400 VALIDATION_FAILED` responses on writes that used to succeed. Review + each such rule: it is doing what its author wrote. + - **Predicates guarded with `has(...)` may now reject.** `has(x)` asks whether the key is + **present**, and a declared field holding `null` is present — so + `has(a) && has(b) && a < b` still faults on `null < null`. Such a rule never enforced + anything on rows with a null value (on any driver that returns its NULL columns); the + fault used to be swallowed and is now reported. **Guard with `!= null`, not `has(...)`:** + + ```diff + - condition: 'has(record.start_date) && has(record.end_date) && record.end_date < record.start_date' + + condition: 'record.start_date != null && record.end_date != null && record.end_date < record.start_date' + ``` + + The rejection message says this explicitly, and `error.fields[0].constraint` carries + `{ reason: 'unevaluable', missingKey?, hint?: 'null-comparison' }` for machine handling. + `has()` remains correct for asking whether an **undeclared** key exists. + + - **A `conditional` rule now always fetches the prior record on update.** Its `when` is + evaluated against the merged record, so without the prior state it read a PATCH as if it + were the whole record. One extra `findOne` per update on objects that declare one. + + **Unchanged, deliberately:** a broken `regex` (`format`), an uncompilable JSON Schema + (`json_schema`), the field-level `requiredWhen` / `readonlyWhen` / option `visibleWhen` + predicates, and a rule that throws all keep their existing fail-open policy. + +- d6bd5a1: Report stored `lookup` references that resolve to nothing (#4551) + + #4441 made the write path refuse an unresolvable reference id, but deliberately + exempted `isSystem` writes so seed replay, package install and boot-time + provisioning keep their ordering freedom. That exemption is unchanged — and it + left a residual: the platform itself could still write a reference into the void + with nothing saying so. + + New: `ObjectQL.inspectDanglingReferences()` — a **read-only** audit that walks + stored rows and reports every non-`readonly` `lookup` / `master_detail` / + `user` / `tree` value that names no row of its declared target. It runs as a leg + of the existing `LifecycleService` sweep, so the finding surfaces without an + operator knowing to go looking for it. + + - **It never rewrites.** The rows were genuinely written; auto-nulling a + dangling id would make the stored data disagree with what happened, and the + remedy (re-seed the target vs. clear the link) is an operator's call. + - **Unknown is not absent.** A probe that cannot run (target unregistered, no + driver, probe throws) counts as `undetermined`; an object whose rows cannot be + listed lands in `unreadableObjects`; a run that hits its row budget names the + object in `truncatedObjects`. So `dangling: []` can never be misread as + "everything is fine". + - **RBAC link tables are scanned first** (`sys_position_permission_set` and the + rest of `plugin-security`'s tables, derived from `PLATFORM_OBJECTS_BY_PACKAGE`): + a dangling row there is a security-surface record resolving to nothing, and + the audience-anchor gate must resolve exactly that permission set to evaluate + the grant. + + The existence oracle is the engine's own — the same predicate #4441's write-path + guard uses — so the report can never be stricter or looser than the rule it + reports on. + + Tuning: `ObjectQLPlugin`'s `lifecycle.referenceAudit` (`enabled`, `rowsPerObject`, + `maxRows`, `objects`). Nothing is authorable in metadata; no spec key was added. + +### Patch Changes + +- c44dd5e: fix(objectql,platform-objects): 一次启动不能证明它自己随即违反的契约 —— ADR-0104 空库自证改为在本次启动写完数据后下结论 (#4769) + + 一个全新部署第一次 `pnpm dev` 全绿(130 rows,0 ERROR),**第二次启动开始永久 10 条 + ERROR**、10 条种子记录写不进去。数据没变、代码没变,只是重启了一次;被拒的正是首启 + 自己写进去的数据。 + + 根因不是哪个值算错了,是**顺序反了**。`sys_migration` 里那两行 + (`adr-0104-file-references` / `adr-0104-value-shapes`)带着 + `{"attested":"datastore-created-empty"}` 写在 `kernel:ready`,而同一次启动的 seed + 还在往里写行。「空库 ⇒ 没有历史值」这个推理成立的前提是**没有数据可写**,而它恰恰 + 写在即将写入 130 行之前 —— 证明落笔那一刻是真的,一秒之后就不是了。于是首启在 + warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的 + 那批行。 + + ## 改了什么 + + **证书必须覆盖它所声称的那批数据。** + + - **写入时机**:新库自证改为在**本次启动自己的数据落定之后**进行 —— + `app:seeded`(inline seed 结算点,含超出 `OS_INLINE_SEED_BUDGET_MS` 后台跑完的 + 那一半),不 seed 的 kernel 仍由 `kernel:ready` 兜底。两条路径进的是同一个幂等 + 调用。 + - **写入前提**:`attestFreshDatastore` 先问引擎「这次启动放行过违反该契约的值吗」。 + 引擎在 warn-first 放行每一个不合形状的值时,用**与 strict 模式完全相同的判定**把 + 它记下来 —— 证明干净需要扫全库,证伪只需要一个反例,而这个反例写路径已经算出来 + 了。任一条被本次启动证伪的迁移 id **不再自证**,部署维持 warn-first(真实且可 + 恢复),并在日志里指名是哪个 `对象.字段` 让这道闸没关上、该跑哪条 `os migrate`。 + 两行一起改:`adr-0104-file-references` 与 `adr-0104-value-shapes` 各自独立判定, + 一个 `cover` 不合形状不牵连 `location`,反之亦然。 + - **写入之后**:证书若在签发之后被本次启动推翻(操作员显式开了 + `OS_ALLOW_LAX_MEDIA_VALUES` / `OS_ALLOW_LAX_VALUE_SHAPES`,或后台 seed 收尾晚于 + 签发),引擎**撤销**它 —— `verified_at` 清空、`blocking` 记上、`details` 保留原 + `attested` 并补一条 `revoked`。只针对**本次启动亲手创建的库**上的自证行:扫过全 + 库的真实迁移证据不会被一次写入的观察推翻。 + + **记忆化的第二张脸也一并修了。** 首启之所以「看起来是绿的」,一半靠的是进程内正好 + 缓存了 `false`。`sys_migration` 在 kernel init 期间才注册,而第一条写可能赶在它之 + 前 —— 那次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。现在区分两种否定: + **问过了、账本说不**(结论,照旧缓存)与**根本问不到**(未注册 / 查询抛错 —— 依旧 + 答 `false`,闸依旧关着,但不记住,下一次写再问一次)。代价是账本存在之前每次写多一 + 次 registry 查表(在任何查询之前就短路),账本可读之后即止。 + + 启动横幅那条 ADR-0104 建议行(`kernel:bootstrapped`)也改为直接读账本而非读记忆化 + 结果 —— 否则一个刚刚自证成功的新部署会被告知去跑一条已经不需要跑的迁移。 + + ## 对既有部署的影响 + + - 数据本来就合规的新部署:行为不变,照旧 born-migrated,启动即 strict。 + - 种子数据不合规的新部署:**不再**发出那张假证书。首启与之后每一次启动一致地停在 + warn-first,并且每次都告诉你是哪一个值、跑哪条命令。数据本身该怎么修还是怎么修 + (showcase 的 `cover` 种子值在 #4774 单独跟踪)。 + - 已经跑过 `os migrate … --apply` 的部署:完全不受影响 —— 扫描得来的证据不经由本 + 次改动的任何路径改写。 + +- a52e2ef: fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560) + + `Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at + insert time, from the request's `ExecutionContext` — and with no authenticated + user (system / anonymous writes: seed replay, package install, boot + provisioning) `applyFieldDefaults` deliberately leaves the field **unset** + rather than stamp a bogus owner. + + The SQL DDL had never heard of the token. `createColumn` passed any non-object + `defaultValue` straight through to `col.defaultTo(dv)`, so the column was + created as `DEFAULT 'current_user'` and the **database** overrode the engine's + decision: every insert that omitted the field stored the literal string + `current_user` in a `lookup('sys_user')` column — a value that is not any user's + id. `?expand` resolves it to nothing, and on an owner / approver field it is a + silent mis-attribution. Found by #4551's dangling-reference audit on its first + run against a real boot; #4441's referential check could never have caught it, + because it inspects the values a **caller** supplied and here nobody supplied + one. + + **The token vocabulary is now declared once, in `@objectstack/spec/data`** + (`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`, + `isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's + insert-time resolution and the driver's DDL read the same set, which is the + actual defect: `'NOW()'` was special-cased in the branch immediately above for + precisely this reason, and `current_user` — the same convention family — simply + had no entry anywhere the DDL could see. A token added to the set tomorrow is + excluded from literal column DEFAULTs automatically, rather than leaking its own + spelling into the database the way this one did. + + **DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation + and the SQLite table rebuild): + + - `'NOW()'` → the driver-native canonical default, exactly as before; + - any other runtime token → **no column default at all** (the engine owns it); + - Expression envelopes (`{ dialect, source }`) → unchanged, no default; + - a real literal → emitted verbatim, unchanged. + + **Existing databases carry the wrong DEFAULT**, so it is corrected through the + managed schema-drift path (#2186) rather than a bespoke migration: a new + `default_mismatch` finding with a `drop_column_default` op, categorised `safe` + (the statement cannot fail and touches no rows). Dev boots with + `autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported + with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use + `ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place, + goes through the existing table rebuild — which now re-materialises every + column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column + keeps the default it always had instead of losing it to the rebuild. + + **Rows already holding the bogus value are NOT rewritten.** That is #4551's + standing rule — report, never rewrite — so they stay visible to the + dangling-reference audit for operators to resolve deliberately. + +- 4c45be1: fix(convention): a best-effort degradation that costs DURABILITY logs `error`, not `warn` — and a gate that enforces it (#4632) + + #4420: the durable suspended-run store attached to a table that was never + created. Every write failed into a `warn` nobody read, every restart dropped all + in-flight approvals, and the process reported perfect health the entire time — + the symptom surfaced a release after the cause. #4460 raised that **one** site to + `error`. This makes it the rule, because the _class_ is what recurs. + + **The rule** (AGENTS.md → "Degradation log levels") is a question, not an + adjective, so an agent can apply it while writing the `catch`: + + > After the degradation, does the system still look "normal" from the outside, + > while something it claims is persisted has not actually landed? + > Yes → `error`. No → `warn`/`info` is right. + + An `error` here owes two things in its first line: the **consequence** (what is + not durable, and that the system will keep looking healthy anyway) and the + **fix** (the composition change that restores durability, or the explicit opt-out + that makes the degradation deliberate). Say it once, not once per failed write. + + **Sites raised to `error`** — each was reviewed individually; escalating a + functional degradation is the mirror-image failure and was deliberately avoided: + + | Where | What was silently lost | + | :----------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | + | `objectql` schema sync, per object | DDL never ran — the object stays registered, routed and rendered while its table/columns do not exist | + | `objectql` schema sync, summary | `info: Schema sync complete` printed over a pass with failures; now an `error` naming the count | + | `objectql` reload-time schema sync | a Studio edit adds a field, the UI shows it, the API accepts it, the column was never created | + | `ObjectQL.syncSchemas()` | an **empty** `catch` — marketplace install and template seeding wrote into tables this failure means do not exist, then reported success | + | `service-automation` wait-timer re-arm (4 paths) | runs stay persisted but nothing re-arms them: every approval paused before the restart hangs forever | + + **Deliberately left at `warn`** — the rule cuts both ways, and over-applying it + trains everyone to skim `error`: the batch→sequential schema-sync fallback (it + _recovers_), and "no job service is registered" on the re-arm path (a declared + absence in a host that never composed auto-resume — nothing was promised and + then broken). + + **It has teeth.** A convention that lives only in AGENTS.md is the same + "declared ≠ enforced" shape this repo keeps paying to fix, so + `pnpm check:durability-log-level` walks the AST for `catch` blocks guarding a + declared vocabulary of durability-critical operations and fails when one + degrades below `error` without rethrowing. It follows same-file helpers (so + extracting a reporter cannot quietly defeat it) and ships its own `--self-test`. + Deliberately narrow: it cannot _discover_ a new durability seam, only stop known + ones from regressing — extend `DURABILITY_CRITICAL_CALLEES` in the same PR that + fixes a new one. + + No API, schema or behaviour changes — only the level, and the text, of what + already-failing paths report. + +- 1ee48bc: fix(objectql,metadata-protocol): a tenant-authored overlay must not read back as a code artifact + + `saveMetaItem` refuses to write an artifact-backed item of a type that has not + opted into overlay writes (`not_overridable`), and it asks + `registry.getArtifactItem` who is artifact-backed. That answer was "anything + whose `_packageId` is not the literal string `sys_metadata`" — a sentinel that + only holds on the save path. The boot-time rehydration of `sys_metadata` + registers each row under its REAL package id (`app.`), which every + runtime-authored item has carried since packages became mandatory. + + So an app the user had just built through Studio (or the AI build agent) came + back from the next kernel rebuild looking code-shipped, and the following edit + was refused with a 403 — permanently. Live capture: two identical `modify_field` + calls on the same object seconds apart, the first published LIVE and the second + `not_overridable`, because the first one's auto-publish triggered the rebuild in + between (cloud#970). + + Provenance is the axis that actually separates the two (ADR-0010 `_provenance`: + `'package'` for loader-introduced items, `'org'` for tenant-authored), so ask it: + the `sys_metadata` hydration now stamps `_provenance: 'org'`, and + `getArtifactItem` no longer treats such an item as an artifact. An item with no + provenance under a real package id is unchanged, so nothing that was protected + becomes writable. + +- 26bb053: fix(objectql): a roll-up registered at RUNTIME must not need a restart to compute + + The engine's roll-up summary index had exactly one invalidation site — + `engine.registerApp` — and the runtime publish path does not go through it: it + registers straight into the registry (`protocol.saveMetaItem` → + `registry.registerObject`). So a kernel that had already performed a single + write — publishing itself writes `sys_metadata` rows — held a summary index + built before the new object existed. Every child write of a freshly published + roll-up then found no descriptor and silently skipped the recompute, leaving the + parent field null until the process restarted. + + That is how an AI-built app's "已完成任务数" shipped permanently empty over + completely correct metadata: the roll-up was configured, the child rows were + seeded with resolved foreign keys, and nothing recomputed (cloud#970). + + `SchemaRegistry` now carries a monotonic `objectRevision`, bumped on every + change to the registered object set, and the engine rebuilds its index whenever + that number has moved — so a registry-derived cache can no longer go stale + through a path that forgot to call the invalidator. + +- ad5fe25: fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless + + `field.zod` defines `user` as "a lookup specialized to the `sys_user` system + object … target fixed to the `sys_user` system object", and `Field.user()` — + unlike `Field.lookup(reference, …)` — takes no target argument and writes + `reference: 'sys_user'` itself. The target is a constant of the type. + + Two callers read `field.reference` raw and so disagreed: the protocol's expand + gate refused `?expand=` with `400 INVALID_FIELD … declares no +target object`, and objectql's expand loop skipped it. Metadata authored without + the redundant `reference` — hand-written JSON, an AI author, a Studio form — was + read as under-specified when it was complete. Live capture (cloud#983): an + AI-built app's very first screen rendered an error page over that 400. + + New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of + "what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the + set those same two callers already share for "is this a reference at all"). Both + halves of the expand path read it, so the gate can no longer refuse a field the + engine would have expanded, nor bless one it skips. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [0800433] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [58434f5] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [8aacf94] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [4c80fd6] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [63b33e6] +- Updated dependencies [6beb708] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [83cf2d3] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [69b509f] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [4b945fc] +- Updated dependencies [1ee48bc] +- Updated dependencies [705e5c8] +- Updated dependencies [f61edce] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [0657f6b] +- Updated dependencies [666f542] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [304423e] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/metadata-protocol@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes @@ -292,8 +1384,8 @@ init(ctx) { … } })`; the engine is `ctx.getService('objectql')`, drivers 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 + 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. diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 66bcf3c4cd..ad9b915f8b 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 7eb024bd44..796be2f960 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,149 @@ # @objectstack/observability +## 17.0.0-rc.2 + +### Patch Changes + +- ff17642: fix(runtime): declarative `defineJob` cron jobs are actually scheduled (#4567) + + Every background job authored as `defineJob({ schedule: { type: 'cron', … } })` + was **silently never scheduled**. `JobSchema.parse` rewrites the cron + `expression` into the canonical expression envelope + (`{ dialect: 'cron', source: '0 1 * * *' }` — the authoring/persistence tier), + but `AppPlugin` handed `job.schedule` verbatim to `IJobService.schedule`, whose + boundary contract documents `expression` as a **bare cron string** because + `CronJobAdapter` passes it straight to croner. croner rejected the object + (`CronPattern: Pattern has to be of type string.`), the throw was swallowed by a + per-job `try/catch` that only `warn`ed, and the author saw a green build and a + green boot with the job never running. `interval` / `once` schedules and + flow `schedule` triggers were unaffected. + + **Fix (contract-first).** The authoring→boundary downgrade now happens at the one + place the two tiers meet — `AppPlugin`'s declarative-job registration, alongside + the existing `retryPolicy` / `timeout` threading — via + `toBoundaryJobSchedule()`. The adapters stay strict: no `typeof === 'object'` + tolerance was added downstream, so the boundary keeps exactly one shape. + A schedule that cannot be reduced to it (unknown type, AST-only or non-`cron` + expression envelope, missing `intervalMs` / `at`) is rejected by name. + + **The failure path is no longer silent.** A job that cannot be scheduled now logs + at **error** level with its own message (`Background job FAILED TO SCHEDULE — it +will never run`), plus a boot summary line when any job failed, and increments + the new `job_schedule_failures_total` counter + (`SEMCONV.jobScheduleFailuresTotal`, labels `app` / `job`) on the observability + metrics registry. "Failed to schedule" no longer shares the quiet `warn` used by + "handler not found in bundle.functions" — the first is an outage of declared + work, the second is a job that was never going to run. + + No authoring change is required: existing `defineJob` cron declarations start + working on upgrade. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 70e5eb29ab..acc44a151c 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 0dc9731c9b..62650394e6 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,458 @@ # @objectstack/platform-objects +## 17.0.0-rc.2 + +### Minor Changes + +- 98877c9: feat(core,platform-objects,spec): the ADR-0119 D2 migration-journal runner — a migration killed mid-run is resumable to completion or compensable to clean, with journal rows proving which (#4617) + + **The gap D1 left open.** ADR-0119 D1 made `engine.transaction()` reachable + through the contract, which is the right answer for multi-write atomicity that + fits in one transaction. Migration-class work does not fit: a million-row + backfill cannot hold one write-lock for its duration, `driver-memory`'s + `beginTransaction` deep-clones the entire database (O(db) per begin), + `ObjectQL.transaction()` binds the **default driver only** so a multi-datasource + migration silently commits part of its work outside it, and a process **killed** + — as distinct from a thrown error — defeats in-process rollback entirely. So the + unit of atomicity is the _chunk_, and durability across chunks is a journal. + + Four consumers had each converged on the same four moves — dry-run preflight, + undo journal, LIFO compensation, re-entrant forward recovery (ADR-0105 D13 + promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, and + D10 master-data distribution #4585). One copy is engineering; four is platform + debt, and the fourth author would have had to rediscover the invariant below + from scratch. + + **New: `runMigrationJournal` (`@objectstack/core`).** Preflight runs every + step's read-only validator before any step writes, so a plan that would fail at + step 3 has not written step 1. Rows are chunked per the `bulk-write.ts` + discipline; each chunk's writes run inside `engine.transaction()`. On failure, + committed chunks are compensated newest-first, each in its own transaction. On + restart, a rediscovered run resumes forward from the first chunk lacking + `chunk_done`, or unwinds, per the plan's `onCrash` policy. Forward and + compensate callbacks receive an `attempt` counter; `attempt > 1` means the prior + outcome is UNKNOWN and the callback must recheck by natural key before + re-writing — the same at-least-once contract `bulk-write.ts` already documents, + reused rather than re-derived. + + **The invariant that carries the design:** `chunk_done(i)` is written **inside** + the chunk's own transaction, so `done ⇔ committed` holds by construction; + `chunk_started(i)` is written autonomously **before** it. That asymmetry is what + gives `started ∧ ¬done` exactly one meaning — _the outcome is unknown_ — which + is the only state a crash can leave and the only state recovery reasons about. + Making both writes symmetric would look tidier and would destroy recovery. + + **New: `sys_migration_journal` (`@objectstack/platform-objects`).** Rows keyed + `(run_id, seq)` under a unique index, so a resumed run that miscomputes its next + sequence fails loudly rather than double-recording an event. Registered + unconditionally alongside `sys_migration` because recovery must be discoverable + with **zero host wiring** — a journal some kernels compose and others do not is + a journal a boot scanner cannot rely on (ADR-0078). Distinct in grain from + `sys_migration`, which holds one durable verdict per named migration; this holds + many rows per _run_. Read-only over the API; writes go through the runner in + system context. + + **The runner refuses rather than degrades**, in four places: the runtime cannot + roll back; any preflight fails; the plan declares `onCrash: 'compensate'` but a + step cannot compensate; or a resume's plan hash disagrees with the journal + (resuming a changed plan would apply chunk boundaries the journal never + described). A compensation failure halts and is journalled — never swallowed — + and the run ends `failed`, not `compensated`, because a database in a state no + clean story covers must not be reported as a tidy rollback. + + **`engineCanRollBack` is now shared.** The two-level probe (engine method AND + default-driver `beginTransaction`) was the same condition written twice — here + and in `batchData`'s atomic gate. It now lives in `@objectstack/core` and + `@objectstack/metadata-protocol` imports it, as a type predicate so callers do + not each re-narrow the optional member by hand. Two copies of "can this runtime + actually roll back?" drift by one clause and leave one caller believing it has + atomicity it does not have. + + Boot reconciliation and `os migrate resume` land separately; `findInterruptedRuns` + is the discovery primitive they will consume, and is exported here. + + **Docs:** ADR-0118 (plugin-reachable transactions) is renumbered **ADR-0119**. + It merged one day after an unrelated ADR-0118 (非用户 actor 的平台契约) and the + earlier merge holds the number; citations of "ADR-0118 D1/D2/D3/D4" written + before 2026-08-03 mean the renumbered record. + +- ce92674: feat(email): declared email templates reach the mail service (#4509) + + Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate` + resolves `(name, locale)` against **`sys_email_template` rows**, and the only + writers of those rows were the built-in auth templates plus a code-constructed + `EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every + door an author can actually use — a stack's `emailTemplates:`, an + `*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked + items in a metadata store nothing read back. So an admin could "fix" the + password-reset email in Studio, get a success toast, and watch users keep + receiving the built-in copy: ADR-0078 false compliance on **authentication + mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049 + enforce-or-remove, route: enforce). + + **`bootstrapDeclaredEmailTemplates`** now materializes declared templates into + `sys_email_template` at boot. Each item is validated through + `EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real + consumer, defaults and all — and projected with `mapTemplateToRow`, which is the + **same** mapping the built-in seeder uses, extracted and shared so the two doors + cannot drift apart. A malformed template warns and is skipped rather than + crashing boot. + + **Runtime writes take effect immediately.** Unlike `webhook`, `email_template` + is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio + save inert until the next restart — the same bug, half-fixed. The plugin also + subscribes to `email_template` metadata changes and re-materializes the single + changed item; withdrawing a template deactivates its rows (across locales) + rather than deleting them. + + **Three breaks sat on this path, not one**, and closing any two of them would + still have shipped a template that never sent: + + - `@objectstack/objectql` never registered a manifest's `emailTemplates:` into + the metadata registry at all — the key was simply missing from the generic + ingestion list, so the bridge's own source was empty. + - The built-in seeder left `managed_by` at the column's `'admin'` default, which + made platform templates masquerade as admin-authored. Since the bridge refuses + to overwrite admin rows, a built-in would have permanently outranked the + template an app declared. Built-ins now stamp `managed_by: 'platform'`. + - Nothing materialized declared metadata into rows. + + **Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule` + (#2909): `sys_email_template` gains `managed_by` / `customized`. Declared + templates re-seed every boot as `managed_by: 'package'`; a row an admin created + (`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never + overwritten, so reworded transactional mail survives redeploys. This is a + separate axis from `is_system`, which keeps its existing meaning for built-ins. + + The `email_template` liveness ledger flips from 13 dead properties to fully + live, with an ADR-0054 runtime proof bound on `subject` + (`email-template-materialization`): it boots a real stack, authors a template + that overrides a built-in auth template, and asserts the **authored** wording is + what reaches the transport. + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +- ce92674: feat(spec)!: retire the standalone `validation` metadata kind (#4509, ADR-0088) + + A validation rule authored as its own artifact bound to nothing and gated no + write. `ValidationRuleSchema` carries **no object-binding key** — no `object`, + no `objectName` — and all six variants are `strictObject`, so an author could + not supply one either. No merge step existed. The only code that expected such a + key was a reference-tracker row scanning a field the schema would have stripped. + Meanwhile the engine evaluates exactly one shape: the object's own + `validations[]` array, on insert and on every matched update row. + + So a rule created through the standalone door — a `*.validation.ts` file, or + Studio's Validations list — parsed, saved, reported success, and intercepted + nothing. Including a `state_machine` rule, which ADR-0020 routes through this + same vocabulary: an author could believe they had locked down record state + transitions and have changed nothing at all. + + Under ADR-0088 the kind fails the admission test on its first clause: a rule has + no independent lifecycle, because it only means something against an object. And + unlike the sibling disconnects closed in this batch, it could not be bridged into + one — the shape has nowhere to name its object. + + **The rule vocabulary is untouched.** `ValidationRuleSchema` and all six + variants are unchanged and fully live; the engine's evaluation path is not + modified by this change. It is the _kind_ that was inert, not the schema. The + liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS` + override (alongside `webhook` and `query`), because an ungoverned live schema is + exactly how the next drift would hide. + + **Migration.** Move the rule into the owning object's `validations:` array — the + rule body is identical, same schema, same six variants: + + ```ts + // before — a standalone *.validation.ts, which never ran + export default defineValidation({ name: 'amount_positive', type: 'script', … }) + + // after — on the object, where rules are evaluated + ObjectSchema.create({ + name: 'invoice', + validations: [{ name: 'amount_positive', type: 'script', … }], + }) + ``` + + Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml` + patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum + member, the schema-map entry, the create seed, Studio's Validations nav item and + its hand-crafted form, and the dangling reference-tracker row. Standalone rows + already in `sys_metadata` are left alone — they were never evaluated, so nothing + changes behaviorally. + +### Patch Changes + +- c44dd5e: fix(objectql,platform-objects): 一次启动不能证明它自己随即违反的契约 —— ADR-0104 空库自证改为在本次启动写完数据后下结论 (#4769) + + 一个全新部署第一次 `pnpm dev` 全绿(130 rows,0 ERROR),**第二次启动开始永久 10 条 + ERROR**、10 条种子记录写不进去。数据没变、代码没变,只是重启了一次;被拒的正是首启 + 自己写进去的数据。 + + 根因不是哪个值算错了,是**顺序反了**。`sys_migration` 里那两行 + (`adr-0104-file-references` / `adr-0104-value-shapes`)带着 + `{"attested":"datastore-created-empty"}` 写在 `kernel:ready`,而同一次启动的 seed + 还在往里写行。「空库 ⇒ 没有历史值」这个推理成立的前提是**没有数据可写**,而它恰恰 + 写在即将写入 130 行之前 —— 证明落笔那一刻是真的,一秒之后就不是了。于是首启在 + warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的 + 那批行。 + + ## 改了什么 + + **证书必须覆盖它所声称的那批数据。** + + - **写入时机**:新库自证改为在**本次启动自己的数据落定之后**进行 —— + `app:seeded`(inline seed 结算点,含超出 `OS_INLINE_SEED_BUDGET_MS` 后台跑完的 + 那一半),不 seed 的 kernel 仍由 `kernel:ready` 兜底。两条路径进的是同一个幂等 + 调用。 + - **写入前提**:`attestFreshDatastore` 先问引擎「这次启动放行过违反该契约的值吗」。 + 引擎在 warn-first 放行每一个不合形状的值时,用**与 strict 模式完全相同的判定**把 + 它记下来 —— 证明干净需要扫全库,证伪只需要一个反例,而这个反例写路径已经算出来 + 了。任一条被本次启动证伪的迁移 id **不再自证**,部署维持 warn-first(真实且可 + 恢复),并在日志里指名是哪个 `对象.字段` 让这道闸没关上、该跑哪条 `os migrate`。 + 两行一起改:`adr-0104-file-references` 与 `adr-0104-value-shapes` 各自独立判定, + 一个 `cover` 不合形状不牵连 `location`,反之亦然。 + - **写入之后**:证书若在签发之后被本次启动推翻(操作员显式开了 + `OS_ALLOW_LAX_MEDIA_VALUES` / `OS_ALLOW_LAX_VALUE_SHAPES`,或后台 seed 收尾晚于 + 签发),引擎**撤销**它 —— `verified_at` 清空、`blocking` 记上、`details` 保留原 + `attested` 并补一条 `revoked`。只针对**本次启动亲手创建的库**上的自证行:扫过全 + 库的真实迁移证据不会被一次写入的观察推翻。 + + **记忆化的第二张脸也一并修了。** 首启之所以「看起来是绿的」,一半靠的是进程内正好 + 缓存了 `false`。`sys_migration` 在 kernel init 期间才注册,而第一条写可能赶在它之 + 前 —— 那次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。现在区分两种否定: + **问过了、账本说不**(结论,照旧缓存)与**根本问不到**(未注册 / 查询抛错 —— 依旧 + 答 `false`,闸依旧关着,但不记住,下一次写再问一次)。代价是账本存在之前每次写多一 + 次 registry 查表(在任何查询之前就短路),账本可读之后即止。 + + 启动横幅那条 ADR-0104 建议行(`kernel:bootstrapped`)也改为直接读账本而非读记忆化 + 结果 —— 否则一个刚刚自证成功的新部署会被告知去跑一条已经不需要跑的迁移。 + + ## 对既有部署的影响 + + - 数据本来就合规的新部署:行为不变,照旧 born-migrated,启动即 strict。 + - 种子数据不合规的新部署:**不再**发出那张假证书。首启与之后每一次启动一致地停在 + warn-first,并且每次都告诉你是哪一个值、跑哪条命令。数据本身该怎么修还是怎么修 + (showcase 的 `cover` 种子值在 #4774 单独跟踪)。 + - 已经跑过 `os migrate … --apply` 的部署:完全不受影响 —— 扫描得来的证据不经由本 + 次改动的任何路径改写。 + +- 5966c2a: feat(spec)!: retire the five keys the advisory lint could never have warned about — mapping `extractQuery`/`errorPolicy`/`batchSize`, contextSelector `includeAll`/`placement` (#4509) + + Five authorable keys parsed, stored, and controlled nothing. What groups them is + not the type they sit on but **why they had to go out in a major rather than + after a deprecation cycle**: four of the five carry schema DEFAULTS, and a + default materialises at parse time — so the liveness advisory lint cannot tell a + value the author wrote from one the schema supplied. Marking them would have + warned on every mapping and every selector in existence, which is why the ledger + recorded them as `_authorWarnSkipped` instead. For a key in that state, removal + is not the escalation after a warning. It is the only channel that ever reaches + the author. + + **The retirement kit:** + + | FROM | TO | Fix | + | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | + | `mapping.extractQuery` | _(removed)_ | Delete the key. Exports run through the ordinary query API (`POST /api/v1/data/:object/query`) — no exporter has ever read a mapping artifact. | + | `mapping.errorPolicy` | _(removed)_ | Delete the key. Error handling on the import path belongs to the import REQUEST's own options, not the stored mapping. | + | `mapping.batchSize` | _(removed)_ | Delete the key. The write path sizes its own batches. **Do not relocate the value** — see below. | + | `app.contextSelectors[].includeAll` | _(removed)_ | Delete the key. Selectors are mandatory-scope; widen `optionsSource.filter` to widen the choices. | + | `app.contextSelectors[].placement` | _(removed)_ | Delete the key. Selectors always render in the sidebar header; `'topbar'` placed nothing. | + + Run `os migrate meta --from 16` to rewrite existing sources automatically. + + **`includeAll` is the one worth reading twice.** It was not unread — it was + deliberately _disobeyed_, and for a security reason. A context selector is a + mandatory scope, so an "All" row would clear the scope on a surface that exists + to be scoped; on Studio's package selector that means listing the platform's own + system/cloud kernel packages to a developer who scoped to their own package. The + renderer never offered an All row regardless of the flag, so `includeAll: false` + hardened nothing and `includeAll: true` unlocked nothing. `STUDIO_APP` shipped + authoring `includeAll: true` against a renderer that ignored it — that authoring + site goes with the key in this change. + + **`batchSize` deliberately offers no rename.** `bulkActionDef.batchSize`, + `connector.batchSize`, `sync.batchSize`, `offline.batchSize`, the seed loader's + and the NoSQL driver cursor's are all LIVE and enforced — but each is a + different key on a different type sizing its own path, and none of them sizes a + mapping import. The rejection says so explicitly, because "removed" plus a + familiar name one line away is exactly how a dead setting gets laundered into a + live-looking one. Same trap `datasource.retryPolicy` had to defuse against + `hook`/`job` `retryPolicy` (which spell the delay `backoffMs`) one issue + earlier. + + Both schemas are `.strict()`, so the keys are deleted from the shape and + rejected with a `guidance` prescription rather than tombstoned; their liveness + rows are deleted rather than kept. The retired ALIAS spellings (`query`, + `onError`, `errorHandling`, `errorMode`, `batch`, `chunkSize`, `skipErrors`, + `showall`, `location`) route to the same prescriptions instead of suggesting a + rename onto a key that is also gone. + + Registered as the ADR-0087 D2 conversion `mapping-inert-keys-removed` and an + extension of `app-dead-authoring-keys-removed`, both wired into the protocol-17 + D3 chain step. The mapping conversion is scoped to the `mappings` collection + deliberately — a stack-wide strip would delete an enforced `batchSize` from + connector, sync, bulk-action and offline shapes. + + `datasource` reached zero dead keys in #4583; `mapping` reaches zero here. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index e178221bd1..7a77c5a873 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.1", + "version": "17.0.0-rc.2", "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 739c90d19f..f9e3c88a8b 100644 --- a/packages/plugins/driver-memory/CHANGELOG.md +++ b/packages/plugins/driver-memory/CHANGELOG.md @@ -1,5 +1,320 @@ # @objectstack/driver-memory +## 17.0.0-rc.2 + +### Major Changes + +- c6d1cb4: refactor(spec,drivers)!: retire `IDataDriver.findStream` — a required method with no caller, whose two main implementations did the opposite of what it promised (#4484, ADR-0049 enforce-or-remove) + + `findStream` was a **required** method on the driver contract — every driver and + every test double had to implement it — documented as the read + + > Optimized for large datasets to avoid memory overflow. + + Three things were true about it at once, and each is worse in the light of the + others. + + **Nothing called it.** Not the query engine (there is no `stream` entry on it), + not REST export, not import, not any bulk-read path. Repo-wide, outside the + contract declaration and the three driver implementations, every single hit was + a test double — and roughly twenty of those satisfied the required method like + this: + + ```ts + findStream() { throw new Error('not implemented'); } + ``` + + Twenty stubs that throw, across four packages, for years, and no test ever went + red. That is not an anecdote about test hygiene; it is the proof of absence. A + method whose every double throws is a method nothing reaches. + + **Two of the three implementations inverted its one guarantee.** `SqlDriver` and + `InMemoryDriver` both did this: + + ```ts + const results = await this.find(object, query, options); // ← the entire result set + for (const row of results) yield row; + ``` + + The whole table is resident in memory before the first `yield`. A caller who + believed the doc comment and reached for `findStream` precisely because a result + set was too large would have hit the overflow it existed to prevent, at exactly + the scale where it mattered. `SqlDriver` carried a `TODO: Use Knex .stream()` + admitting it. + + **The one real implementation dropped a parameter.** `MongoDBDriver._findStream` + did walk a cursor — but it was the only read in that driver never routed through + `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently + discarded `query.fields`. (#4459 unified `find`/`findOne` onto `buildFindOptions` + and recorded in its TSDoc that `_findStream` was left out. This removal subsumes + that divergence rather than fixing it — there is nothing left to fix it for.) + + Rather than manufacture a caller to justify three implementations, the method is + retired. If a cursor-based read is wanted, it should arrive **with** the caller + that needs it, so the contract can be shaped by a real requirement instead of + being reverse-engineered from a doc comment nobody could test. + + **Migration.** + + | Wrote | Write instead | + | ---------------------------------------------------------- | ---------------------------------------------------------- | + | `for await (const row of driver.findStream(obj, q)) { … }` | page `driver.find(obj, { ...q, limit, offset })` in a loop | + | `findStream(…) { … }` on your own driver | delete the method (see below) | + | `findStream() { throw new Error('ni'); }` in a test double | delete the line | + + Paging `find()` is not a downgrade from what `findStream` actually did: on SQL + and memory it is strictly better (bounded pages instead of one full + materialisation), and the paged read is the one with an **enforced** guarantee — + `IDataDriver.find` requires a total order across the whole walk, checked by the + shared `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES` fixtures in + `data/pagination-conformance.ts`. `findStream` never had a conformance case at + all. + + **Driver authors: nothing breaks on you.** An implementation left in place still + compiles — an extra method is not an error on a class or a widened object — it is + simply never reached, so deleting it is cleanup you can do whenever. The break is + on the **caller** side: `driver.findStream(...)` no longer type-checks, and there + were no callers. + + **No tombstone, deliberately.** The other v17 retirements tombstone their key so + authoring it fails loudly with a prescription. That would be noise here. + `DriverInterfaceSchema` describes a contract that code _implements_; nothing in + either repository ever ran a driver object through `.parse()`, so a + `retiredKey()` there would carry its prescription to no one. The channel that can + carry it is `tsc`, and `tsc` reports it where it is actionable — at a call site. + The key is removed from the schema and from `IDataDriver`, and the retirement is + registered as the `data-driver-find-stream-retired` semantic entry in the + protocol-17 chain step (ADR-0087 D3), so `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool all carry it. There is no + `os migrate meta` step: a driver is code, never stack metadata, so the chain has + no source to rewrite. + + **Left standing on purpose:** `DriverCapabilities.streaming`, the capability flag + whose only referent was this method. It has no readers either (and the values + written into it were already wrong — `SqlDriver` declared `streaming: false` + while implementing `findStream`, `InMemoryDriver` declared `true` for the + copy-everything version), but removing a key from the capabilities literal breaks + every driver that writes it, third-party included, and the same audit should + cover the other ~30 flags in one pass rather than one at a time. Tracked as + #4634. + +- d9fa683: refactor(spec)!: retire the 31 inert `DriverCapabilities` bits — declared by every driver, read by nothing (#4634, ADR-0049) + + The #4484 findStream close-out left one loose end: `DriverCapabilities.streaming` + described a contract method that no longer exists — and a full liveness audit of + the record (#4634, across objectstack + cloud, objectui confirmed clean) found + `streaming` was not the exception but the rule. Of 34 declared bits, **three** + have a decision-making reader and **thirty-one** were written by every driver + and consulted by no engine, planner, REST layer or renderer: + + - Their `.describe()` strings promised engine adaptation that was never built + ("If false, ObjectQL will fetch all records and filter in memory" — no such + fallback ever keyed off the bit). + - Zero readers let values go WRONG unnoticed: `SqlDriver` declared + `streaming: false` while implementing `findStream`; `InMemoryDriver` declared + `streaming: true` over a full-table read — the exact inverse of the guarantee. + - The real mechanism everywhere else is **method presence**: transactions gate + on `driver.beginTransaction`, aggregate pushdown on + `typeof driver.aggregate === 'function'`, schema sync on + `typeof driver.syncSchema === 'function'`, and the REQUIRED CRUD/bulk methods + are called unconditionally. + + Survivors (each with a named reader — the bits method presence cannot carry): + + | bit | reader | + | ---------------------- | ---------------------------------------------------------------------------------------- | + | `queryDateGranularity` | engine aggregate dispatch (`engine.ts`), `checkDateBucketParity` (`@objectstack/verify`) | + | `autonumber` | engine defers autonumber generation to the driver (`engine.ts`) | + | `batchSchemaSync` | engine ANDs it with `syncSchemasBatch` presence (`engine.ts` / `plugin.ts`) | + + Migration (FROM → TO): + + - Any of the 31 bits (`create`/`read`/`update`/`delete`, `bulkCreate`/ + `bulkUpdate`/`bulkDelete`, `transactions`/`savepoints`/`isolationLevels`, + `queryFilters`/`queryAggregations`/`querySorting`/`queryPagination`/ + `queryWindowFunctions`/`querySubqueries`/`queryCTE`/`joins`, + `fullTextSearch`/`jsonQuery`/`geospatialQuery`/`streaming`/`jsonFields`/ + `arrayFields`/`vectorSearch`, `schemaSync`/`migrations`/`indexes`, + `connectionPooling`/`preparedStatements`/`queryCache`) in a `supports` + literal or a `DriverConfig.capabilities` object → **delete the key**. Each is + tombstoned (`retiredKey()`), not silently stripped: authoring one is a `tsc` + error against `IDataDriver.supports` and a parse error carrying the per-key + prescription, which names the mechanism that actually decides the behaviour. + - `batchSchemaSync` dropped its `.default(false)` for `.optional()` — absence + already meant `false` at both readers, so `supports: {}` is now a valid, + minimal advertisement. If you read `capabilities.batchSchemaSync` from a + _parsed_ config and relied on the materialised `false`, treat absence as + `false` (both engine readers always did). + - Driver packages: `InMemoryDriver.supports` is now `{}`, + `MongoDBDriver.supports` is `{ batchSchemaSync: true }`, `SqlDriver.supports` + is `{ queryDateGranularity, autonumber: true, batchSchemaSync: false }`. + Reading a removed bit off these literals no longer type-checks — and no code + in any repository did. + - A future capability (streaming reads, vector search, …) returns **with its + caller and its reader in the same change** — the enforce route of ADR-0049 — + never as a dangling boolean. + + The retirement kit: 31 `retiredKey()` tombstones on the non-strict schema + (parse + `tsc` both audible; the schema IS parsed via + `DriverConfigSchema.capabilities` and its SQL/NoSQL extensions); ADR-0087 D3 + semantic migration `driver-capabilities-inert-bits-removed` (a driver is CODE, + never stack metadata — `supports` lives in driver classes and `DriverConfig` + is plugin TS configuration, so there is no stored row or stack source for a D2 + conversion to rewrite; the stack-tree neighbour `datasource.capabilities` was + retired separately in #4583); baselines (`authorable-surface.json` [RETIRED] + lines, `json-schema.manifest.json`) regenerated deliberately; compiler-API pin + asserting every retired bit is unwritable (`undefined`) and every live bit is + not, sabotage-verified both ways (S1 schema resurrection, S2 driver literal + resurrection). + + No runtime behaviour changes — that impossibility is the point: every removed + bit had zero readers, and the three live bits keep theirs. + +### Minor Changes + +- ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + + Four independent surfaces where the answer a caller received contradicted the + contract the surface declares. All four were found driving a real showcase boot + against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + + - **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + + - **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + + - **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + + - **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + + These change responses that were observably wrong, so callers coded against the + buggy shapes — a 200 on a missing record, a 400 on a capability denial — will + see different status codes. Graded `minor` on that basis rather than `patch`. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/plugins/driver-memory/package.json b/packages/plugins/driver-memory/package.json index f7071fb078..288bd3b0df 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.1", + "version": "17.0.0-rc.2", "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 029099850e..54a5b87f9b 100644 --- a/packages/plugins/driver-mongodb/CHANGELOG.md +++ b/packages/plugins/driver-mongodb/CHANGELOG.md @@ -1,5 +1,400 @@ # @objectstack/driver-mongodb +## 17.0.0-rc.2 + +### Major Changes + +- c6d1cb4: refactor(spec,drivers)!: retire `IDataDriver.findStream` — a required method with no caller, whose two main implementations did the opposite of what it promised (#4484, ADR-0049 enforce-or-remove) + + `findStream` was a **required** method on the driver contract — every driver and + every test double had to implement it — documented as the read + + > Optimized for large datasets to avoid memory overflow. + + Three things were true about it at once, and each is worse in the light of the + others. + + **Nothing called it.** Not the query engine (there is no `stream` entry on it), + not REST export, not import, not any bulk-read path. Repo-wide, outside the + contract declaration and the three driver implementations, every single hit was + a test double — and roughly twenty of those satisfied the required method like + this: + + ```ts + findStream() { throw new Error('not implemented'); } + ``` + + Twenty stubs that throw, across four packages, for years, and no test ever went + red. That is not an anecdote about test hygiene; it is the proof of absence. A + method whose every double throws is a method nothing reaches. + + **Two of the three implementations inverted its one guarantee.** `SqlDriver` and + `InMemoryDriver` both did this: + + ```ts + const results = await this.find(object, query, options); // ← the entire result set + for (const row of results) yield row; + ``` + + The whole table is resident in memory before the first `yield`. A caller who + believed the doc comment and reached for `findStream` precisely because a result + set was too large would have hit the overflow it existed to prevent, at exactly + the scale where it mattered. `SqlDriver` carried a `TODO: Use Knex .stream()` + admitting it. + + **The one real implementation dropped a parameter.** `MongoDBDriver._findStream` + did walk a cursor — but it was the only read in that driver never routed through + `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently + discarded `query.fields`. (#4459 unified `find`/`findOne` onto `buildFindOptions` + and recorded in its TSDoc that `_findStream` was left out. This removal subsumes + that divergence rather than fixing it — there is nothing left to fix it for.) + + Rather than manufacture a caller to justify three implementations, the method is + retired. If a cursor-based read is wanted, it should arrive **with** the caller + that needs it, so the contract can be shaped by a real requirement instead of + being reverse-engineered from a doc comment nobody could test. + + **Migration.** + + | Wrote | Write instead | + | ---------------------------------------------------------- | ---------------------------------------------------------- | + | `for await (const row of driver.findStream(obj, q)) { … }` | page `driver.find(obj, { ...q, limit, offset })` in a loop | + | `findStream(…) { … }` on your own driver | delete the method (see below) | + | `findStream() { throw new Error('ni'); }` in a test double | delete the line | + + Paging `find()` is not a downgrade from what `findStream` actually did: on SQL + and memory it is strictly better (bounded pages instead of one full + materialisation), and the paged read is the one with an **enforced** guarantee — + `IDataDriver.find` requires a total order across the whole walk, checked by the + shared `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES` fixtures in + `data/pagination-conformance.ts`. `findStream` never had a conformance case at + all. + + **Driver authors: nothing breaks on you.** An implementation left in place still + compiles — an extra method is not an error on a class or a widened object — it is + simply never reached, so deleting it is cleanup you can do whenever. The break is + on the **caller** side: `driver.findStream(...)` no longer type-checks, and there + were no callers. + + **No tombstone, deliberately.** The other v17 retirements tombstone their key so + authoring it fails loudly with a prescription. That would be noise here. + `DriverInterfaceSchema` describes a contract that code _implements_; nothing in + either repository ever ran a driver object through `.parse()`, so a + `retiredKey()` there would carry its prescription to no one. The channel that can + carry it is `tsc`, and `tsc` reports it where it is actionable — at a call site. + The key is removed from the schema and from `IDataDriver`, and the retirement is + registered as the `data-driver-find-stream-retired` semantic entry in the + protocol-17 chain step (ADR-0087 D3), so `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool all carry it. There is no + `os migrate meta` step: a driver is code, never stack metadata, so the chain has + no source to rewrite. + + **Left standing on purpose:** `DriverCapabilities.streaming`, the capability flag + whose only referent was this method. It has no readers either (and the values + written into it were already wrong — `SqlDriver` declared `streaming: false` + while implementing `findStream`, `InMemoryDriver` declared `true` for the + copy-everything version), but removing a key from the capabilities literal breaks + every driver that writes it, third-party included, and the same audit should + cover the other ~30 flags in one pass rather than one at a time. Tracked as + #4634. + +- d9fa683: refactor(spec)!: retire the 31 inert `DriverCapabilities` bits — declared by every driver, read by nothing (#4634, ADR-0049) + + The #4484 findStream close-out left one loose end: `DriverCapabilities.streaming` + described a contract method that no longer exists — and a full liveness audit of + the record (#4634, across objectstack + cloud, objectui confirmed clean) found + `streaming` was not the exception but the rule. Of 34 declared bits, **three** + have a decision-making reader and **thirty-one** were written by every driver + and consulted by no engine, planner, REST layer or renderer: + + - Their `.describe()` strings promised engine adaptation that was never built + ("If false, ObjectQL will fetch all records and filter in memory" — no such + fallback ever keyed off the bit). + - Zero readers let values go WRONG unnoticed: `SqlDriver` declared + `streaming: false` while implementing `findStream`; `InMemoryDriver` declared + `streaming: true` over a full-table read — the exact inverse of the guarantee. + - The real mechanism everywhere else is **method presence**: transactions gate + on `driver.beginTransaction`, aggregate pushdown on + `typeof driver.aggregate === 'function'`, schema sync on + `typeof driver.syncSchema === 'function'`, and the REQUIRED CRUD/bulk methods + are called unconditionally. + + Survivors (each with a named reader — the bits method presence cannot carry): + + | bit | reader | + | ---------------------- | ---------------------------------------------------------------------------------------- | + | `queryDateGranularity` | engine aggregate dispatch (`engine.ts`), `checkDateBucketParity` (`@objectstack/verify`) | + | `autonumber` | engine defers autonumber generation to the driver (`engine.ts`) | + | `batchSchemaSync` | engine ANDs it with `syncSchemasBatch` presence (`engine.ts` / `plugin.ts`) | + + Migration (FROM → TO): + + - Any of the 31 bits (`create`/`read`/`update`/`delete`, `bulkCreate`/ + `bulkUpdate`/`bulkDelete`, `transactions`/`savepoints`/`isolationLevels`, + `queryFilters`/`queryAggregations`/`querySorting`/`queryPagination`/ + `queryWindowFunctions`/`querySubqueries`/`queryCTE`/`joins`, + `fullTextSearch`/`jsonQuery`/`geospatialQuery`/`streaming`/`jsonFields`/ + `arrayFields`/`vectorSearch`, `schemaSync`/`migrations`/`indexes`, + `connectionPooling`/`preparedStatements`/`queryCache`) in a `supports` + literal or a `DriverConfig.capabilities` object → **delete the key**. Each is + tombstoned (`retiredKey()`), not silently stripped: authoring one is a `tsc` + error against `IDataDriver.supports` and a parse error carrying the per-key + prescription, which names the mechanism that actually decides the behaviour. + - `batchSchemaSync` dropped its `.default(false)` for `.optional()` — absence + already meant `false` at both readers, so `supports: {}` is now a valid, + minimal advertisement. If you read `capabilities.batchSchemaSync` from a + _parsed_ config and relied on the materialised `false`, treat absence as + `false` (both engine readers always did). + - Driver packages: `InMemoryDriver.supports` is now `{}`, + `MongoDBDriver.supports` is `{ batchSchemaSync: true }`, `SqlDriver.supports` + is `{ queryDateGranularity, autonumber: true, batchSchemaSync: false }`. + Reading a removed bit off these literals no longer type-checks — and no code + in any repository did. + - A future capability (streaming reads, vector search, …) returns **with its + caller and its reader in the same change** — the enforce route of ADR-0049 — + never as a dangling boolean. + + The retirement kit: 31 `retiredKey()` tombstones on the non-strict schema + (parse + `tsc` both audible; the schema IS parsed via + `DriverConfigSchema.capabilities` and its SQL/NoSQL extensions); ADR-0087 D3 + semantic migration `driver-capabilities-inert-bits-removed` (a driver is CODE, + never stack metadata — `supports` lives in driver classes and `DriverConfig` + is plugin TS configuration, so there is no stored row or stack source for a D2 + conversion to rewrite; the stack-tree neighbour `datasource.capabilities` was + retired separately in #4583); baselines (`authorable-surface.json` [RETIRED] + lines, `json-schema.manifest.json`) regenerated deliberately; compiler-API pin + asserting every retired bit is unwritable (`undefined`) and every live bit is + not, sabotage-verified both ways (S1 schema resurrection, S2 driver literal + resurrection). + + No runtime behaviour changes — that impossibility is the point: every removed + bit had zero readers, and the three live bits keep theirs. + +### Patch Changes + +- 9b43ee2: test(drivers): the filter-logic standard now covers the backend it was counted without (#4405) + + `FILTER_LOGIC_CASES` (#3774) opens by calling itself the standard "the four + independent FilterCondition backends are each checked against". Five backends + exist. `driver-mongodb`'s `translateFilter` was missed, not excluded — an + independent implementation whose `$and`/`$or`/`$not` translation shares no line + of code with the SQL compiler or the in-memory matcher, and the only one whose + target language cannot spell the standard directly: MongoDB has no + document-level `$not` at all (the server answers `unknown top level operator: +$not`), so a negation has to leave as `$nor`, and a branch's own keys have to + stay in one document while `$and`/`$or` clauses are lifted beside them. That + route was never checked against the shared cases. Both DEBT rows the #4363 gate + recorded are now cleared, and `scripts/check-driver-conformance.mjs` reports + `ok` for every cell of the matrix. + + **`driver-mongodb` runs the table twice, and the split is deliberate.** + `mongodb-filter-logic-translation.test.ts` drives every shared case through + `translateFilter` and evaluates the emitted MongoDB _document_ over the shared + fixture — a pure function, no server, so it always runs. That matters here more + than anywhere: `mongodb-memory-server` downloads a ~123 MB binary from + fastdl.mongodb.org, and a defect only a downloadable binary can catch is a + defect nobody catches on a restricted network. Its in-process reader is strict + by construction — every shape it does not model throws instead of evaluating to + true, a document-level `$not` included — and its own discrimination is pinned by + cases that require a widened document to FAIL the case it widens, so "all green" + cannot mean "the reader says yes to everything". + `mongodb-filter-logic-conformance.test.ts` runs the same table against a real + mongod and answers the one question the first half cannot — does MongoDB agree? + — skipping cleanly (never silently) when the binary is unreachable. + + **`driver-sqlite-wasm` runs the table through its own engine.** It inherits + `SqlDriver`'s filter compiler, so nothing is re-implemented; what the suite pins + is that a nested `(… AND …) OR (… AND …)` survives the custom sql.js dialect + that compiles, binds and marshals it — the same seam its temporal and pagination + suites cover for their clauses. Tracked as DEBT rather than EXEMPT because + "inherits, therefore fine" is the assumption those suites exist to disprove; the + suite is what disproves it. + + **No divergence was found.** `translateFilter` answers all seventeen shared + cases correctly today, `$not`-inside-a-branch and nested `$and`-inside-`$or` + included, so no translation change ships here — what changes is that the next + edit to it cannot quietly widen a filter. Both suites were verified to be + discriminating rather than decorative by reintroducing the #3774 miscompile + (propagating `or` into a branch's own contents): 15 of the mongodb translation + suite's 26 tests fail, and 13 of the wasm suite's 18. + + `packages/spec`'s `filter-logic-conformance.ts` header now says five and names + the fifth — a code comment; no schema, export or generated artifact moved. + +- ec975f1: fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419) + + `findOne` reads a single row, which makes its predicate the only thing between + the caller and _an arbitrary record_. When the predicate is missing the result is + not `null` — it is the object's **first row**: a real, plausible-looking record + with nothing to do with the request, which the `if (!row)` check every call site + already has cannot catch, and which then propagates into whatever is computed + next. Reported downstream: line items defaulting their price from the first + product in the catalog rather than the selected one, and "is this deal already + closed?" answered against an unrelated record while the write that followed + correctly targeted the intended id. A throw would have been caught in + development; a `null` would have been caught by the null-check. A valid-looking + wrong record defeats both. + + **Breaking — `findOne` now refuses a query that selects nothing in particular.** + + FROM → TO: + + | Was | Now write | Meaning | + | ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------ | + | `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | + | | `findOne(o, { search: 'Acme' })` | the record this search finds | + | | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | + | | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site | + + One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest + one", or switch to `find(o, { limit: 1 })` if any row will do. The error names + all four. `find` and `count` are unchanged — returning or counting every row is + an honest answer; only `findOne`'s implicit "just one of them" turns a missing + predicate into a confidently wrong record. The guard reads the CALLER's + predicate, before RLS/sharing middleware injects its own: a tenant filter + narrows which rows are visible, it does not make "whichever comes first" + something the caller asked for. + + **Two silent drops that produced the same wrong record are fixed with it.** + + - **`findOne({ search })` applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion lived inline in `find` and nowhere else, + while `find` and `findOne` are checked against the SAME legal-key set — so + `search` passed the gate, rode onto the AST, and reached a driver. No driver + reads `ast.search`. The read therefore ran with no predicate at all and + `limit: 1` did the rest. The expansion is now one method both call. + - **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It + translated `query.where` and dropped the rest, so `findOne({ orderBy })` did + not return the newest record — it returned whichever document the scan reached + first. `find` and `_findStream` in the same driver had always handled all + three. This one matters beyond Mongo: the guard above tells an unpredicated + caller to reach for `orderBy`, and an escape hatch one backend ignores is not + an escape hatch. No ordering is IMPOSED when the caller supplies none — both + drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo + "never sorting" is corrected, since it cited the dropped parameter as + agreement. + + **And a gate so the class does not come back.** A drift pin walks + `ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an + observable effect — on the AST the driver receives, on the driver options, or in + an explicit "not executed, and here is why" entry (only `limit`, which the + contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through + two rounds of hardening because nothing asked that question. + + Together with #4346 (`filter` → `where` folds on every entry point) and #4400 + (unknown option keys throw), a read parameter the engine does not execute now + fails at the call site instead of quietly changing the answer. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/driver-mongodb/package.json b/packages/plugins/driver-mongodb/package.json index 741910821a..8bb43d298c 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.1", + "version": "17.0.0-rc.2", "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 5d3c509de4..570e85f115 100644 --- a/packages/plugins/driver-sql/CHANGELOG.md +++ b/packages/plugins/driver-sql/CHANGELOG.md @@ -1,5 +1,440 @@ # @objectstack/driver-sql +## 17.0.0-rc.2 + +### Major Changes + +- c6d1cb4: refactor(spec,drivers)!: retire `IDataDriver.findStream` — a required method with no caller, whose two main implementations did the opposite of what it promised (#4484, ADR-0049 enforce-or-remove) + + `findStream` was a **required** method on the driver contract — every driver and + every test double had to implement it — documented as the read + + > Optimized for large datasets to avoid memory overflow. + + Three things were true about it at once, and each is worse in the light of the + others. + + **Nothing called it.** Not the query engine (there is no `stream` entry on it), + not REST export, not import, not any bulk-read path. Repo-wide, outside the + contract declaration and the three driver implementations, every single hit was + a test double — and roughly twenty of those satisfied the required method like + this: + + ```ts + findStream() { throw new Error('not implemented'); } + ``` + + Twenty stubs that throw, across four packages, for years, and no test ever went + red. That is not an anecdote about test hygiene; it is the proof of absence. A + method whose every double throws is a method nothing reaches. + + **Two of the three implementations inverted its one guarantee.** `SqlDriver` and + `InMemoryDriver` both did this: + + ```ts + const results = await this.find(object, query, options); // ← the entire result set + for (const row of results) yield row; + ``` + + The whole table is resident in memory before the first `yield`. A caller who + believed the doc comment and reached for `findStream` precisely because a result + set was too large would have hit the overflow it existed to prevent, at exactly + the scale where it mattered. `SqlDriver` carried a `TODO: Use Knex .stream()` + admitting it. + + **The one real implementation dropped a parameter.** `MongoDBDriver._findStream` + did walk a cursor — but it was the only read in that driver never routed through + `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently + discarded `query.fields`. (#4459 unified `find`/`findOne` onto `buildFindOptions` + and recorded in its TSDoc that `_findStream` was left out. This removal subsumes + that divergence rather than fixing it — there is nothing left to fix it for.) + + Rather than manufacture a caller to justify three implementations, the method is + retired. If a cursor-based read is wanted, it should arrive **with** the caller + that needs it, so the contract can be shaped by a real requirement instead of + being reverse-engineered from a doc comment nobody could test. + + **Migration.** + + | Wrote | Write instead | + | ---------------------------------------------------------- | ---------------------------------------------------------- | + | `for await (const row of driver.findStream(obj, q)) { … }` | page `driver.find(obj, { ...q, limit, offset })` in a loop | + | `findStream(…) { … }` on your own driver | delete the method (see below) | + | `findStream() { throw new Error('ni'); }` in a test double | delete the line | + + Paging `find()` is not a downgrade from what `findStream` actually did: on SQL + and memory it is strictly better (bounded pages instead of one full + materialisation), and the paged read is the one with an **enforced** guarantee — + `IDataDriver.find` requires a total order across the whole walk, checked by the + shared `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES` fixtures in + `data/pagination-conformance.ts`. `findStream` never had a conformance case at + all. + + **Driver authors: nothing breaks on you.** An implementation left in place still + compiles — an extra method is not an error on a class or a widened object — it is + simply never reached, so deleting it is cleanup you can do whenever. The break is + on the **caller** side: `driver.findStream(...)` no longer type-checks, and there + were no callers. + + **No tombstone, deliberately.** The other v17 retirements tombstone their key so + authoring it fails loudly with a prescription. That would be noise here. + `DriverInterfaceSchema` describes a contract that code _implements_; nothing in + either repository ever ran a driver object through `.parse()`, so a + `retiredKey()` there would carry its prescription to no one. The channel that can + carry it is `tsc`, and `tsc` reports it where it is actionable — at a call site. + The key is removed from the schema and from `IDataDriver`, and the retirement is + registered as the `data-driver-find-stream-retired` semantic entry in the + protocol-17 chain step (ADR-0087 D3), so `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool all carry it. There is no + `os migrate meta` step: a driver is code, never stack metadata, so the chain has + no source to rewrite. + + **Left standing on purpose:** `DriverCapabilities.streaming`, the capability flag + whose only referent was this method. It has no readers either (and the values + written into it were already wrong — `SqlDriver` declared `streaming: false` + while implementing `findStream`, `InMemoryDriver` declared `true` for the + copy-everything version), but removing a key from the capabilities literal breaks + every driver that writes it, third-party included, and the same audit should + cover the other ~30 flags in one pass rather than one at a time. Tracked as + #4634. + +- d9fa683: refactor(spec)!: retire the 31 inert `DriverCapabilities` bits — declared by every driver, read by nothing (#4634, ADR-0049) + + The #4484 findStream close-out left one loose end: `DriverCapabilities.streaming` + described a contract method that no longer exists — and a full liveness audit of + the record (#4634, across objectstack + cloud, objectui confirmed clean) found + `streaming` was not the exception but the rule. Of 34 declared bits, **three** + have a decision-making reader and **thirty-one** were written by every driver + and consulted by no engine, planner, REST layer or renderer: + + - Their `.describe()` strings promised engine adaptation that was never built + ("If false, ObjectQL will fetch all records and filter in memory" — no such + fallback ever keyed off the bit). + - Zero readers let values go WRONG unnoticed: `SqlDriver` declared + `streaming: false` while implementing `findStream`; `InMemoryDriver` declared + `streaming: true` over a full-table read — the exact inverse of the guarantee. + - The real mechanism everywhere else is **method presence**: transactions gate + on `driver.beginTransaction`, aggregate pushdown on + `typeof driver.aggregate === 'function'`, schema sync on + `typeof driver.syncSchema === 'function'`, and the REQUIRED CRUD/bulk methods + are called unconditionally. + + Survivors (each with a named reader — the bits method presence cannot carry): + + | bit | reader | + | ---------------------- | ---------------------------------------------------------------------------------------- | + | `queryDateGranularity` | engine aggregate dispatch (`engine.ts`), `checkDateBucketParity` (`@objectstack/verify`) | + | `autonumber` | engine defers autonumber generation to the driver (`engine.ts`) | + | `batchSchemaSync` | engine ANDs it with `syncSchemasBatch` presence (`engine.ts` / `plugin.ts`) | + + Migration (FROM → TO): + + - Any of the 31 bits (`create`/`read`/`update`/`delete`, `bulkCreate`/ + `bulkUpdate`/`bulkDelete`, `transactions`/`savepoints`/`isolationLevels`, + `queryFilters`/`queryAggregations`/`querySorting`/`queryPagination`/ + `queryWindowFunctions`/`querySubqueries`/`queryCTE`/`joins`, + `fullTextSearch`/`jsonQuery`/`geospatialQuery`/`streaming`/`jsonFields`/ + `arrayFields`/`vectorSearch`, `schemaSync`/`migrations`/`indexes`, + `connectionPooling`/`preparedStatements`/`queryCache`) in a `supports` + literal or a `DriverConfig.capabilities` object → **delete the key**. Each is + tombstoned (`retiredKey()`), not silently stripped: authoring one is a `tsc` + error against `IDataDriver.supports` and a parse error carrying the per-key + prescription, which names the mechanism that actually decides the behaviour. + - `batchSchemaSync` dropped its `.default(false)` for `.optional()` — absence + already meant `false` at both readers, so `supports: {}` is now a valid, + minimal advertisement. If you read `capabilities.batchSchemaSync` from a + _parsed_ config and relied on the materialised `false`, treat absence as + `false` (both engine readers always did). + - Driver packages: `InMemoryDriver.supports` is now `{}`, + `MongoDBDriver.supports` is `{ batchSchemaSync: true }`, `SqlDriver.supports` + is `{ queryDateGranularity, autonumber: true, batchSchemaSync: false }`. + Reading a removed bit off these literals no longer type-checks — and no code + in any repository did. + - A future capability (streaming reads, vector search, …) returns **with its + caller and its reader in the same change** — the enforce route of ADR-0049 — + never as a dangling boolean. + + The retirement kit: 31 `retiredKey()` tombstones on the non-strict schema + (parse + `tsc` both audible; the schema IS parsed via + `DriverConfigSchema.capabilities` and its SQL/NoSQL extensions); ADR-0087 D3 + semantic migration `driver-capabilities-inert-bits-removed` (a driver is CODE, + never stack metadata — `supports` lives in driver classes and `DriverConfig` + is plugin TS configuration, so there is no stored row or stack source for a D2 + conversion to rewrite; the stack-tree neighbour `datasource.capabilities` was + retired separately in #4583); baselines (`authorable-surface.json` [RETIRED] + lines, `json-schema.manifest.json`) regenerated deliberately; compiler-API pin + asserting every retired bit is unwritable (`undefined`) and every live bit is + not, sabotage-verified both ways (S1 schema resurrection, S2 driver literal + resurrection). + + No runtime behaviour changes — that impossibility is the point: every removed + bit had zero readers, and the three live bits keep theirs. + +### Minor Changes + +- ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + + Four independent surfaces where the answer a caller received contradicted the + contract the surface declares. All four were found driving a real showcase boot + against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + + - **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + + - **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + + - **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + + - **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + + These change responses that were observably wrong, so callers coded against the + buggy shapes — a 200 on a missing record, a 400 on a capability denial — will + see different status codes. Graded `minor` on that basis rather than `patch`. + +### Patch Changes + +- a52e2ef: fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560) + + `Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at + insert time, from the request's `ExecutionContext` — and with no authenticated + user (system / anonymous writes: seed replay, package install, boot + provisioning) `applyFieldDefaults` deliberately leaves the field **unset** + rather than stamp a bogus owner. + + The SQL DDL had never heard of the token. `createColumn` passed any non-object + `defaultValue` straight through to `col.defaultTo(dv)`, so the column was + created as `DEFAULT 'current_user'` and the **database** overrode the engine's + decision: every insert that omitted the field stored the literal string + `current_user` in a `lookup('sys_user')` column — a value that is not any user's + id. `?expand` resolves it to nothing, and on an owner / approver field it is a + silent mis-attribution. Found by #4551's dangling-reference audit on its first + run against a real boot; #4441's referential check could never have caught it, + because it inspects the values a **caller** supplied and here nobody supplied + one. + + **The token vocabulary is now declared once, in `@objectstack/spec/data`** + (`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`, + `isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's + insert-time resolution and the driver's DDL read the same set, which is the + actual defect: `'NOW()'` was special-cased in the branch immediately above for + precisely this reason, and `current_user` — the same convention family — simply + had no entry anywhere the DDL could see. A token added to the set tomorrow is + excluded from literal column DEFAULTs automatically, rather than leaking its own + spelling into the database the way this one did. + + **DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation + and the SQLite table rebuild): + + - `'NOW()'` → the driver-native canonical default, exactly as before; + - any other runtime token → **no column default at all** (the engine owns it); + - Expression envelopes (`{ dialect, source }`) → unchanged, no default; + - a real literal → emitted verbatim, unchanged. + + **Existing databases carry the wrong DEFAULT**, so it is corrected through the + managed schema-drift path (#2186) rather than a bespoke migration: a new + `default_mismatch` finding with a `drop_column_default` op, categorised `safe` + (the statement cannot fail and touches no rows). Dev boots with + `autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported + with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use + `ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place, + goes through the existing table rebuild — which now re-materialises every + column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column + keeps the default it always had instead of losing it to the rebuild. + + **Rows already holding the bogus value are NOT rewritten.** That is #4551's + standing rule — report, never rewrite — so they stay visible to the + dangling-reference audit for operators to resolve deliberately. + +- ec975f1: fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419) + + `findOne` reads a single row, which makes its predicate the only thing between + the caller and _an arbitrary record_. When the predicate is missing the result is + not `null` — it is the object's **first row**: a real, plausible-looking record + with nothing to do with the request, which the `if (!row)` check every call site + already has cannot catch, and which then propagates into whatever is computed + next. Reported downstream: line items defaulting their price from the first + product in the catalog rather than the selected one, and "is this deal already + closed?" answered against an unrelated record while the write that followed + correctly targeted the intended id. A throw would have been caught in + development; a `null` would have been caught by the null-check. A valid-looking + wrong record defeats both. + + **Breaking — `findOne` now refuses a query that selects nothing in particular.** + + FROM → TO: + + | Was | Now write | Meaning | + | ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------ | + | `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | + | | `findOne(o, { search: 'Acme' })` | the record this search finds | + | | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | + | | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site | + + One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest + one", or switch to `find(o, { limit: 1 })` if any row will do. The error names + all four. `find` and `count` are unchanged — returning or counting every row is + an honest answer; only `findOne`'s implicit "just one of them" turns a missing + predicate into a confidently wrong record. The guard reads the CALLER's + predicate, before RLS/sharing middleware injects its own: a tenant filter + narrows which rows are visible, it does not make "whichever comes first" + something the caller asked for. + + **Two silent drops that produced the same wrong record are fixed with it.** + + - **`findOne({ search })` applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion lived inline in `find` and nowhere else, + while `find` and `findOne` are checked against the SAME legal-key set — so + `search` passed the gate, rode onto the AST, and reached a driver. No driver + reads `ast.search`. The read therefore ran with no predicate at all and + `limit: 1` did the rest. The expansion is now one method both call. + - **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It + translated `query.where` and dropped the rest, so `findOne({ orderBy })` did + not return the newest record — it returned whichever document the scan reached + first. `find` and `_findStream` in the same driver had always handled all + three. This one matters beyond Mongo: the guard above tells an unpredicated + caller to reach for `orderBy`, and an escape hatch one backend ignores is not + an escape hatch. No ordering is IMPOSED when the caller supplies none — both + drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo + "never sorting" is corrected, since it cited the dropped parameter as + agreement. + + **And a gate so the class does not come back.** A drift pin walks + `ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an + observable effect — on the AST the driver receives, on the driver options, or in + an explicit "not executed, and here is why" entry (only `limit`, which the + contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through + two rounds of hardening because nothing asked that question. + + Together with #4346 (`filter` → `where` folds on every entry point) and #4400 + (unknown option keys throw), a read parameter the engine does not execute now + fails at the call site instead of quietly changing the answer. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/plugins/driver-sql/package.json b/packages/plugins/driver-sql/package.json index 6de0cf56e7..cef800e02a 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.1", + "version": "17.0.0-rc.2", "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 09eff5c68b..33e7f2204f 100644 --- a/packages/plugins/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/plugins/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,208 @@ # @objectstack/driver-sqlite-wasm +## 17.0.0-rc.2 + +### Patch Changes + +- 9b43ee2: test(drivers): the filter-logic standard now covers the backend it was counted without (#4405) + + `FILTER_LOGIC_CASES` (#3774) opens by calling itself the standard "the four + independent FilterCondition backends are each checked against". Five backends + exist. `driver-mongodb`'s `translateFilter` was missed, not excluded — an + independent implementation whose `$and`/`$or`/`$not` translation shares no line + of code with the SQL compiler or the in-memory matcher, and the only one whose + target language cannot spell the standard directly: MongoDB has no + document-level `$not` at all (the server answers `unknown top level operator: +$not`), so a negation has to leave as `$nor`, and a branch's own keys have to + stay in one document while `$and`/`$or` clauses are lifted beside them. That + route was never checked against the shared cases. Both DEBT rows the #4363 gate + recorded are now cleared, and `scripts/check-driver-conformance.mjs` reports + `ok` for every cell of the matrix. + + **`driver-mongodb` runs the table twice, and the split is deliberate.** + `mongodb-filter-logic-translation.test.ts` drives every shared case through + `translateFilter` and evaluates the emitted MongoDB _document_ over the shared + fixture — a pure function, no server, so it always runs. That matters here more + than anywhere: `mongodb-memory-server` downloads a ~123 MB binary from + fastdl.mongodb.org, and a defect only a downloadable binary can catch is a + defect nobody catches on a restricted network. Its in-process reader is strict + by construction — every shape it does not model throws instead of evaluating to + true, a document-level `$not` included — and its own discrimination is pinned by + cases that require a widened document to FAIL the case it widens, so "all green" + cannot mean "the reader says yes to everything". + `mongodb-filter-logic-conformance.test.ts` runs the same table against a real + mongod and answers the one question the first half cannot — does MongoDB agree? + — skipping cleanly (never silently) when the binary is unreachable. + + **`driver-sqlite-wasm` runs the table through its own engine.** It inherits + `SqlDriver`'s filter compiler, so nothing is re-implemented; what the suite pins + is that a nested `(… AND …) OR (… AND …)` survives the custom sql.js dialect + that compiles, binds and marshals it — the same seam its temporal and pagination + suites cover for their clauses. Tracked as DEBT rather than EXEMPT because + "inherits, therefore fine" is the assumption those suites exist to disprove; the + suite is what disproves it. + + **No divergence was found.** `translateFilter` answers all seventeen shared + cases correctly today, `$not`-inside-a-branch and nested `$and`-inside-`$or` + included, so no translation change ships here — what changes is that the next + edit to it cannot quietly widen a filter. Both suites were verified to be + discriminating rather than decorative by reintroducing the #3774 miscompile + (propagating `or` into a branch's own contents): 15 of the mongodb translation + suite's 26 tests fail, and 13 of the wasm suite's 18. + + `packages/spec`'s `filter-logic-conformance.ts` header now says five and names + the fifth — a code comment; no schema, export or generated artifact moved. + +- 24915d2: fix(driver-sqlite-wasm): a `RETURNING` write is a write — persist it (#4518) + + A file-backed `sqlite-wasm` database flushed its schema at boot and then + recorded nothing else. Every table was on disk; every row written after schema + sync lived only in the WASM heap and died with the process. Reopening the file + found a complete, empty database. + + **Cause.** The Knex dialect picked its execution branch from _"does this + statement return rows"_ — and then marked the database dirty only on the other, + row-less branch. `INSERT … RETURNING *` returns rows, so it executed on the + row-returning branch and never set the flag. Since the `on-disconnect` flush is + gated on the same flag, nothing rescued it afterwards either: **both** persist + strategies dropped the write. ObjectQL writes through `RETURNING *` (it hands + the stored row back to the caller), so this covered essentially all business + data, along with `knex.raw('INSERT …')` and any other mutation arriving without + a Knex `method`. + + **Fix.** "Does this statement change the database?" is now one exported + predicate — `statementMutatesDatabase(sql, method)` — classifying by Knex method + _and_ SQL text, applied at a single funnel after execution. It is independent of + which branch executed the statement, so a mutation can no longer slip through by + returning rows, by arriving without a method, or by taking a branch that forgot + to say so. Transaction control still routes to `noteTransactionControl`, which + keeps deferring flushes until the transaction closes (#1494), and mutating + `PRAGMA` assignments (`auto_vacuum`, `user_version`) now count as writes too. + + **What changes for you.** Nothing to author. File-backed wasm SQLite now + actually persists under `on-write` / `debounced:*`, and `disconnect()` is a real + durability boundary: when it returns, committed data is on disk. This is what + `bootStack({ databaseFile })` in `@objectstack/verify` needed to make `stop()` → + second `bootStack` a genuine cold boot — the suspended-run restart proof + ADR-0019 promises is now asserted end to end in the dogfood gate. Expect more + disk writes than before on a file-backed dev database, because previously there + were almost none. + + **One internal signature moved.** `WasmSqliteConnection.markDirty(method?)` is + now `markDirty()`. It used to re-filter the caller's Knex method against its own + allowlist, which made "did this mutate?" a decision taken in two places that + could — and did — disagree. If you call it directly, drop the argument; the + dialect classifies, the connection obeys. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/driver-sql@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/driver-sqlite-wasm/package.json b/packages/plugins/driver-sqlite-wasm/package.json index 7868c38a41..bdf70e8f19 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.1", + "version": "17.0.0-rc.2", "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 c836e59dd5..903e003daa 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/embedder-openai +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 95d2e90932..3c9dd915c7 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.1", + "version": "17.0.0-rc.2", "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 62202c4fcb..dc604bfa95 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/knowledge-memory +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/service-knowledge@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 2f4b95fb9f..edd8c838f7 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.1", + "version": "17.0.0-rc.2", "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 dee3ab38b8..e93d38b894 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/knowledge-ragflow +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/service-knowledge@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 89c566a9a5..5b1cca4a6c 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.1", + "version": "17.0.0-rc.2", "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 c231bcf3e0..cce7b13192 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,426 @@ # @objectstack/plugin-approvals +## 17.0.0-rc.2 + +### Minor Changes + +- 2826d1e: fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + + A flow paused at an `approval` node, a deploy, then an approver clicking + Approve: the request row flipped to `approved`, the UI toasted success — and + the flow never moved. No next-stage request, no error, the record's mirrored + status frozen mid-workflow. Approval flows pause for days by design, so a + restart mid-flight is the normal case: every release could quietly zombify + every in-flight approval, with the approvers none the wiser. + + Durable suspended runs (#1518) had shipped and were not the missing piece. Two + other things were. + + **The wiring could enable a store over a table nobody had created.** Object + registration and store activation resolve different services in different + phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin + declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, + warned, and continued; `start()` then attached the DB-backed store anyway. Every + suspend failed with `no such table: sys_automation_run` into a log line nobody + read, pauses silently stayed in memory, and the next restart lost them all. + Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an + engine-less kernel must still boot); a registration missed at `init()` is + retried at `start()`, which still lands before ObjectQL's schema sync; the + store is never attached when registration did not happen, and says so at + **error** level instead of warning; the table is probed once at boot so a + broken setup surfaces there rather than one failed write at a time; and a + failed durable write of a paused run is logged at error — it is data loss in + waiting, not a warning. + + **A reported resume failure read as success.** `AutomationEngine.resume()` + answers a lost run by _returning_ `{ success: false }`, never by throwing. + `ApprovalService` discarded that return value, and `decide()` counted only a + thrown error as failure — so a decision against a dead run came back + `resumed: true`, HTTP 200. Resume failures are now classified + (`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining + `PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is + distinguishable from a store that is merely unreachable, and the raw resume + route maps them to 404 / 503 / 409. + + Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which + reads the suspension store, unlike `getRun()`, and throws rather than answering + `false` when the store is unreadable — pre-flights every flow-advancing + operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the + zombie half-state is never created rather than merely reported: the decision + fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A + resume that fails after the decision is durable can no longer be undone, but it + now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of + reporting success. A concurrent duplicate resume stays benign — the engine's + idempotency guard is doing its job — and reports through the new optional + `resumeError` field. Recall and revise-window cancellation stay non-fatal by + design (they abandon the request), but log at error with the reason instead of + swallowing it. Compositions with no automation engine attached are unaffected. + + Existing zombie requests from affected deployments (already `approved`, run + stranded) are not repaired by this change — `releaseDeadRunRequests` only + sweeps requests that are still `pending`. + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +### Patch Changes + +- 5a84d41: fix(approvals): record an admin override of a staffed approver slate AS an override (#4466) + + An admin who is not in a request's `pending_approvers` may still act on it — the + `#3424` privileged-override path exists so a request routed to an unstaffed + position, or to approvers who have all left, is not undecidable forever. The + override is defensible; what was not is what the audit trail recorded. + + `sys_approval_action` had no override column at all. So an admin overriding a + properly-staffed slate wrote a row **byte-for-byte identical** to the designated + approver approving normally: a reader of the timeline saw `approve` by the admin + and could not tell whether the admin _was_ an approver or _overrode_ the ones who + were, and the bypassed approver's later `409 INVALID_STATE` was the only trace — + existing only if they happened to try. The platform knows at decision time (it + took the `isOverrideActor` branch to admit the call at all), so this was dropped + information, not unavailable information. The whole point of an approval record + is to answer "who authorized this, and were they entitled to?". + + `sys_approval_action` now carries **`via_override`** (boolean, optional), set on + exactly the actions admitted by that branch — `decideNode`'s approve/reject and + `reassign`'s admin rescue. It is surfaced on `ApprovalActionRow.via_override` + (`@objectstack/spec/contracts`), returned by `listActions`, and added to the + object's `highlightFields` and two grid list views so a timeline can say + "overrode the approver slate" instead of rendering it as an ordinary approval. + + Three distinctions the column keeps apart deliberately: + + - **`true`** — the actor held no slot in the slate and was admitted only by the + override branch. + - **`false`** — checked, and it was not an override. An admin who _is_ a + designated approver is approving normally and records `false`: the marker is + about which branch admitted the call, not about whether the actor holds admin + rights. + - **absent** — a row written before this column existed. "Not recorded" is not + the same claim as "not an override", so `rowFromAction` maps `null` to + `undefined` rather than to `false`. + + Additive and nullable, so this needs no data migration: existing rows keep + working and simply read as unrecorded. Levelled `patch` rather than `minor` + because nothing an author writes changes — but note it _is_ an observable + behaviour change on a read surface: `listActions` responses and the + `sys_approval_action` grid views now carry a field consumers did not see before, + and `sys_approval_action` gains a column on next schema sync. + +- 0b795da: fix(approvals): the record lock now holds for predicate (`multi`) updates (#4778) + + The ADR-0019 record lock — "while a record has a pending `sys_approval_request`, + block edits to it" — was enforced only for updates that reach the hook with an + `input.id`. The engine extracts that id from a **scalar** `where.id` alone; an + operator object (`{ $in: [...] }`) or any other predicate is a multi-row write + that routes to `updateMany` and arrives with no id. The hook opened with + `if (!id) return`, so it read _"no row was resolved"_ as _"there is nothing to + authorize"_ when the truth was _"nothing was ever queried"_. + + Rewriting the very same edit as `multi: true` therefore walked straight past the + lock: + + ```ts + // rec_1 carries a pending approval, lockRecord is not disabled + await ql.update( + "crm_opportunity", + { amount: 999 }, + { where: { id: "rec_1" } } + ); // RECORD_LOCKED + await ql.update( + "crm_opportunity", + { amount: 999 }, + { where: { id: { $in: ["rec_1"] } }, multi: true } + ); // went through + await ql.update( + "crm_opportunity", + { amount: 999 }, + { where: { name: "x" }, multi: true } + ); // went through + ``` + + No privilege was needed for that bypass — not an `admin` role, not `isSystem`, + not `lockRecord: false`, not a whitelisted `approvalStatusField`. Every caller + shape that can spell a predicate (SDK, ObjectQL, a flow's `update_record`) could + produce it. It is the same fail-open reasoning fixed for `sys_attachment` + (#4757) and `sys_comment` (#4630), in the one place where it needed no + privilege at all. + + **The hook now resolves the rows a write touches before deciding.** By-id writes + are unchanged (the driver writes by primary key, so the rest of `where` must not + narrow the verdict). A predicate write is decided by intersecting the caller's + predicate with the records that are actually locked — which is also what keeps + it cheap: the query is bounded by the object's **pending approvals**, never by + the update's match set, so a mass update of 50 000 unlocked rows costs one + bookkeeping probe and is allowed. An unscoped `multi` update over the whole + table reaches every locked row of the object and is refused while any is held. + + **Fail-closed, both ways.** Past 1 000 locked records — the bound the attachment + and comment guards use — or if the intersection query fails, the write is + refused rather than allowed: the lock could not prove the write misses a locked + row. The approvals bookkeeping being unreadable at all stays the one fail-open, + as before: this hook is global over every object, so a kernel without + `sys_approval_request` would otherwise refuse every update in the deployment. + Both the bookkeeping and the match-set resolution are read under a **system** + context — a guard's own input must never be narrowed by the caller's + visibility, since a locked row you cannot read is still a row you may not write. + + **Every exemption moved with the guard**, which is the other way this class of + fix goes wrong — a guard extended to more rows that carries only its deny rules + turns a fail-open into a false-positive. `isSystem`, the `admin` override, the + `approvalStatusField` status mirror, `lockRecord: false` and the owning run's + `flowRunId` (#3456 / #3712) all decide a predicate write exactly as they decide + a by-id write, each pinned by tests on both predicate shapes. Refusals now name + the record and object that are locked. + +- c2a1134: fix(approvals): find the zombie requests nothing was looking at (#4469) + + #4460 stopped new zombies being produced; the rows already stuck had no mechanism + to find or release them. The failure shape (#4420) is a request flipped to + `approved` / `rejected` / `returned` whose `flow_run_id` points at a run that no + longer exists — the decision landed, the flow never moved. Any deployment on + 17.0.0-rc.1 that hit the wiring hole and crossed a restart mid-approval can be + carrying these rows. + + `releaseDeadRunRequests` could not see them, and the reason is worth stating + plainly: it scans `status: 'pending'`, and the very step that zombifies a request + is the one that takes it OUT of `pending`. The act of breaking it removed it from + the only sweeper's field of view — a large part of why this class of failure + stayed silent. It could not have answered the question even if it had looked: its + liveness oracle is `getRun`, which reads the execution LOG and returns `null` for + a perfectly ALIVE suspended run after a restart. It treats `null` as alive + (conservative, and correct for what it does) — which is exactly why it has no way + to say "this run is really gone". + + Adds `ApprovalService.inspectStrandedRequests()`, which uses BOTH oracles and + reports only rows that fail both: + + - `hasSuspendedRun(runId) === false` — the suspension store itself says no live + pause exists. It THROWS when the store cannot be read, and that case is + SKIPPED and counted as `undetermined`, never condemned: an unreadable store + means "unknown", and a storage outage must not be published as a lost run. + - `getRun(runId) == null` — no terminal history row either. A run that merely + finished is not stranded; a request whose run neither waits nor ever completed + is. + + **It reports; it never rewrites.** No status is changed and no run is cancelled. + The decision genuinely happened — a human approved or rejected — and silently + rolling it back would make the audit trail disagree with the facts. The report + carries what an operator needs to decide: which requests are stuck at which step, + and what the mirrored status field on the business record still reads (usually + the stale value the user is staring at). Whether to re-run the downstream actions + or re-open the approval is a judgement call this cannot make. + + It rides the existing escalation/dead-run sweep clock, so the finding surfaces in + the logs without an operator knowing to go looking for it. `recalled` is + deliberately out of scope: a recall abandons its run on purpose, and reporting + those would bury the real findings under expected ones. + + New export: `StrandedApprovalRequest` (the report row shape). + +- 25784cf: fix(automation,approvals): 节点类型校验推迟到插件贡献完成之后 —— approval flow 不再被误报"运行时会失败" (#4771) + + showcase 每次冷启都打印 8 条断言:这些 flow "will fail at execution time"。8 条全是假的。 + `AutomationServicePlugin.start()` 从 ObjectQL registry 拉起 flow 并**当场**校验节点类型,而 + `ApprovalsServicePlugin.start()` 在 0.8 秒后才注册 `approval` 执行器 —— 校验器在词汇表还没 + 成型的时候就下了结论。 + + 真正的代价不是噪音,是信号丢失:**真的没装 approvals 插件**的部署会得到一模一样的 8 条告警, + 所以这条 warn 无法区分"健康"和"坏掉",信噪比为 0。 + + ADR-0018 明确把节点词汇表定义为**开放、可运行时扩展**的(插件通过 + `registerNodeExecutor(type)` 贡献类型)。因此校验只在词汇表**封闭**的那一刻才成立: + + - `AutomationEngine.sealNodeTypeVocabulary()` —— 宣告词汇表封闭,对**所有**已注册 flow 跑一次 + 权威校验,每个有问题的 flow warn 一条。`AutomationServicePlugin` 在 `kernel:bootstrapped` + 调用它(严格晚于每个插件的 `start()` 和每个 `kernel:ready` handler —— 本插件自己的 + `kernel:ready` 还会再注册一批 flow,别的插件也可能在它的 `kernel:ready` 里贡献执行器)。 + - `AutomationEngine.getUnknownNodeTypeAudit(): UnknownNodeTypeAuditEntry[]` —— 同一发现的 + **状态**形态,供 host(CLI 启动摘要、健康检查)直接读,而不是去 grep 日志。与 + `getTriggerBindingAudit()` 同一套路。 + - 封闭之后 `registerFlow` **恢复即时告警**:Studio 发布 / dev reload 进正在运行的服务器时, + 词汇表确实是完整的,那句断言此时为真。所以这是时序修复,不是把告警静音。 + + 告警文案也随之改成它现在能承诺的事:"Every plugin has started, so nothing will register them + now — these nodes fail at execution time with NO_EXECUTOR",并给出补救动作。 + + 一并修掉同一缺陷类的另一半:`ApprovalsServicePlugin` 在**拿不到 automation 引擎**时,把 + "`approval` 节点没注册"记成 `info` —— 而 dev 的默认日志级别是 `warn`,于是**真降级发生时反而 + 看不见**(#4632:静默降级必须响亮)。现在是 `warn`,写明后果(该部署里每个 ADR-0019 approval + flow 都会以 NO_EXECUTOR 失败)和补救(装 `@objectstack/service-automation`)。`catch` 同时收窄 + 到"服务查找"这一步,`registerApprovalNode` 内部真出错时会以自己的身份抛出,而不再被贴上 + "no automation engine" 的错误标签;`automation` 服务存在但不接受节点执行器的分支从前**一条日志 + 都不打**,现在同样 warn。 + + **嵌入式 host 注意**:直接 `new AutomationEngine()` 而不经过 `AutomationServicePlugin` 的宿主, + 需要在自己的插件都装好之后调用一次 `sealNodeTypeVocabulary()`,才能拿到这条告警(以及之后的 + 即时校验)。 + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index c6f0f0a125..f493c9d27b 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.1", + "version": "17.0.0-rc.2", "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 1457b3ca77..1b2807011a 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,306 @@ # @objectstack/plugin-audit +## 17.0.0-rc.2 + +### Major Changes + +- be90dea: fix(plugin-audit,rest)!: `sys_comment` derives its access from the record its thread names (#4630) + + Attachments derive their visibility from the parent record; comments derived + nothing. On the _same_ record, with the _same_ user, the two answered + differently: + + ``` + user: rep2 (does NOT own and cannot read the opportunity) + GET /api/v1/data/crm_opportunity?$filter=["id","=","1A7n…"] → 200, 0 rows + GET /api/v1/data/sys_attachment?$filter=["parent_id","=","1A7n…"] → 200, 0 rows + GET /api/v1/data/sys_comment?$filter=["thread_id","=","crm_opportunity:1A7n…"] + → 200, 1 row + POST /api/v1/data/sys_comment {"thread_id":"crm_opportunity:", …} → 201 Created + ``` + + `sys_comment` is public, has no owner column, and hides its parent inside a + string (`thread_id` = `{object_name}:{record_id}`), so neither OWD/sharing nor + RLS ever narrowed it. Because `enable.feeds` is opt-OUT (spec default `true`), + every object in every app carried that org-wide readable, org-wide writable + side-channel — a deployment that carefully authored OWD, sharing rules and RLS + on its records still leaked their discussion. + + `AuditPlugin` now installs the same two-part kit `service-storage` installs for + `sys_attachment`, keyed off `thread_id`'s parent: + + - **read** — a `find`/`findOne`/`count`/`aggregate` middleware intersects every + query with the threads whose record the caller can actually read (resolved + through the caller-scoped engine, so the parent's own OWD/sharing/RLS/CRUD + decide). `count()` is filtered identically to `find()`, so a list `total` + cannot leak the hidden rows' existence either. + - **write** — `beforeInsert` requires READ on the record the thread names; + `beforeUpdate` / `beforeDelete` require the caller to be the comment's AUTHOR + or to hold EDIT on that record. `author_id` is server-stamped from the + session, so a client-supplied value never wins. + + Everything fails CLOSED: a `thread_id` that names no record — the dangling + `"crm_opportunity:"` above, a free-form thread, a thread on `sys_comment` + itself — is refused on write and excluded on read, and a filter that cannot be + computed denies all rather than falling open. Refusals answer **403 + `RECORD_NOT_ACCESSIBLE`** (the standard error catalog, per ADR-0112 — a generic + permission condition takes a catalogued code rather than a new synonym), with + `error.object` naming the record's object. + + **Breaking for deployments that depended on the gap.** Reads that used to + return other people's comments now return fewer rows (or none), and writes that + used to 201 now 403. Specifically: + + - Listing `sys_comment` without being able to read the parent record → the row + is gone, not merely unlabelled. Panels that render a thread must be reached by + a principal who can read the record. + - Threads whose `thread_id` is not `{object_name}:{record_id}` are no longer + usable at all: creating one is refused, and existing rows become invisible to + everyone but system context. Migrate free-form threads to a real record + reference (or keep them under a system-context surface). + - Deleting or editing another user's comment now requires EDIT on the record. + Note also that `sys_comment` delete already needed a permission set carrying + `allowDelete` — the `member_default` baseline has none (ADR-0090 D5). + - Posting a comment no longer requires the client to send `author_id` (it is + stamped); a client that sends someone else's is silently corrected rather than + believed. + + Orthogonal and unchanged: `enable.feeds` (`FEEDS_DISABLED`) still gates whether + an object has comments at all, and anonymous callers are still refused with 401 + before any of this runs. + +### Minor Changes + +- ce5242c: feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586) + + better-auth owns every write to the identity tables (`sys_member`, `sys_user`, + `sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on + purpose** — the route already authorized the action under better-auth's own ACL, + and ADR-0092 D2 refuses user-context writes to those tables outright. The + consequence was that the human who clicked _make admin_ was known exactly once, + in the hook layer where the session exists, and then discarded: every + `trackHistory` transition on `sys_member` recorded `user_id: null` / "system", + and `sys_user_permission_set.granted_by` was written null by the auto-grant. + "Who made this person an org admin?" had no answer in the platform's own audit + log. + + **What changed** + + A request-scoped attribution seam, general rather than a `sys_member` special + case: + + | Layer | Before | After | + | :--------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | + | `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED | + | `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` | + | better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open | + | audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody | + | `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row | + + Outside a request scope nothing changes: writes stay bare `{ isSystem: true }` + and audit rows keep recording `null`. Absence is still never upgraded into a + caller, and never written as a sentinel string (ADR-0118 D1/D2). + + **Hard constraint — attribution is not authority** + + `attributedUserId` is read by exactly one consumer, the audit writer, and by no + security middleware. It never becomes `ExecutionContext.userId`, so it is never + the subject the engine authorizes as: not RLS `current_user`, not the ownership + stamp, not permission resolution. A context carrying only `attributedUserId` + authorizes exactly like an empty context (ANONYMOUS), and a context carrying it + beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing + identity writes as the human would re-adjudicate a decision better-auth already + made — the second adjudication track ADR-0095 D3 closed. The constraint is + pinned by tests at three layers: the engine seam + (`packages/objectql/src/engine.test.ts`), the better-auth adapter + (`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the + live HTTP route (a plain member still cannot promote themselves). + + **For authors and plugin developers** + + `attributedUserId` is authorable on `ExecutionContext` and readable as + `ctx.provenance?.attributedUserId` in hooks. Use it to answer _who is + responsible_; keep using `ctx.session` / `ctx.user` to decide _what is + permitted_. The two are separate fields precisely so the distinction cannot be + blurred by accident. + +- 04b9776: feat(plugin-audit)!: retire `sys_comment.visibility` and `sys_comment.reply_count` (#4756, ADR-0049) + + Both fields were modelled with **zero** runtime consumers — nothing in this repo, + in `objectui`, or in `cloud` ever read or maintained either one. ADR-0049 + enforce-or-remove; maintainer decision: remove both. Same disposition, and for + the same stated reason, as `sys_attachment.share_type` / `sys_attachment.visibility` + in #2755 ("attachment access is derived from the parent record"). + + **REMOVED — `sys_comment.visibility`** (`'public' | 'internal' | 'private'`, + defaulted `'public'`). + + This one is a **security-looking key with no gate behind it**, which is the + primary reason it goes rather than stays. No code path consulted it: not + `enforceFeedsCapability`, not the record-level gates added in #4630, not the + REST layer, not objectui's discussion panel. A comment an author marked + `private` was visible to exactly the same people as a `public` one — an app + author (or an AI authoring metadata) reading the field list would reasonably + believe otherwise, and get a silent security failure instead of an error. That + is the Prime Directive #10 trap in its textbook shape. + + There is **no replacement key**: after #4630, who can see a comment is decided + by the record-level permissions of the record its `thread_id` names — one + coherent rule. A per-row enum layered on top would be a second source of truth + for the same question. The enum's only genuinely missing meaning ("hidden from + external/portal principals") depends on external principals existing at all, + which waits on ADR-0090 D11's `externalSharingModel`; today there is nobody to + hide a comment from. This does not foreclose that design — when portals land, + a visibility key can return **enforce-first**, with a real gate and tests. + + **FROM → TO:** stop sending `visibility` on `sys_comment` writes; to restrict + who sees a discussion, restrict who can read the record `thread_id` points at. + + **REMOVED — `sys_comment.reply_count`** (`number`, `defaultValue: 0`, + `readonly: true`). + + Never incremented anywhere, and `readonly` meant an author could not set it by + hand either, so every row read `0` forever — a UI binding an "N replies" badge + to it rendered `0` for every thread. Deliberately **not** replaced by an + `afterInsert`/`afterDelete` roll-up: the predicate/bulk write-hook gaps tracked + by #4770 / #4778 / #4779 (a hook that returns early without a single-record id + lets the whole bulk operation through) are exactly where a hook-maintained + counter drifts — a bulk delete of replies would never decrement it. A counter + that drifts is worse than no counter, because both the UI and an AI reading the + record trust it. If a badge needs the number, aggregate `parent_id` children at + read time; a designed roll-up can be revisited once #4775's family has settled + bulk-hook semantics. + + **FROM → TO:** replace reads of `reply_count` with a count of `sys_comment` rows + whose `parent_id` is the comment's id. + + **Stored data.** Existing databases keep both columns as **unmanaged leftovers** + — no migration, matching #2755. What changes where: + + - **Reads are loud everywhere.** The read-axis gates (#4134 / #4226) resolve + field names from the object schema, not from the table, so a filter, sort, + `select` or `expand` naming `visibility` / `reply_count` now answers + `400 INVALID_FIELD` on every deployment, leftover column or not. A "0 replies" + badge that silently lied becomes an error that names itself. + - **Writes are loud on new databases only.** A database provisioned after this + change has no such column, so the write fails at the driver and is mapped to + the same `400 INVALID_FIELD` envelope. On a pre-existing database the leftover + column still accepts a value nothing will ever read — record validation does + not reject undeclared keys. Dropping the two columns is an optional manual + cleanup, not a requirement. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 00a1adad38..4aca401049 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.1", + "version": "17.0.0-rc.2", "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 1490b6a8b1..78e11bb570 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,332 @@ # Changelog +## 17.0.0-rc.2 + +### Minor Changes + +- ce5242c: feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586) + + better-auth owns every write to the identity tables (`sys_member`, `sys_user`, + `sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on + purpose** — the route already authorized the action under better-auth's own ACL, + and ADR-0092 D2 refuses user-context writes to those tables outright. The + consequence was that the human who clicked _make admin_ was known exactly once, + in the hook layer where the session exists, and then discarded: every + `trackHistory` transition on `sys_member` recorded `user_id: null` / "system", + and `sys_user_permission_set.granted_by` was written null by the auto-grant. + "Who made this person an org admin?" had no answer in the platform's own audit + log. + + **What changed** + + A request-scoped attribution seam, general rather than a `sys_member` special + case: + + | Layer | Before | After | + | :--------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | + | `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED | + | `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` | + | better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open | + | audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody | + | `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row | + + Outside a request scope nothing changes: writes stay bare `{ isSystem: true }` + and audit rows keep recording `null`. Absence is still never upgraded into a + caller, and never written as a sentinel string (ADR-0118 D1/D2). + + **Hard constraint — attribution is not authority** + + `attributedUserId` is read by exactly one consumer, the audit writer, and by no + security middleware. It never becomes `ExecutionContext.userId`, so it is never + the subject the engine authorizes as: not RLS `current_user`, not the ownership + stamp, not permission resolution. A context carrying only `attributedUserId` + authorizes exactly like an empty context (ANONYMOUS), and a context carrying it + beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing + identity writes as the human would re-adjudicate a decision better-auth already + made — the second adjudication track ADR-0095 D3 closed. The constraint is + pinned by tests at three layers: the engine seam + (`packages/objectql/src/engine.test.ts`), the better-auth adapter + (`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the + live HTTP route (a plain member still cannot promote themselves). + + **For authors and plugin developers** + + `attributedUserId` is authorable on `ExecutionContext` and readable as + `ctx.provenance?.attributedUserId` in hooks. Use it to answer _who is + responsible_; keep using `ctx.session` / `ctx.user` to decide _what is + permitted_. The two are separate fields precisely so the distinction cannot be + blurred by accident. + +### Patch Changes + +- f2eb850: fix(plugin-auth): 限流计数器改为惰性解析 kernel cache —— 修掉误报的告警,也修掉「共享限流从未生效」的功能洞 (#4772) + + `pnpm dev`(showcase)每次冷启都会打一条: + + ``` + WARN [auth] no cache service registered — rate-limit counters use a per-process in-memory + store; a multi-node deployment needs a shared cache (Redis) to enforce limits globally + ``` + + 而 `CacheServicePlugin` 就在 **21ms 后**注册好了,它本来就在已加载插件列表里。这条告警把运维引向「你需要 Redis」,接完 Redis 还是同一条告警 —— 因为缺的不是 Redis。 + + **这不只是日志误报。** `AuthPlugin.init()` 里那次 `getServiceAsync('cache')` + 探测的结论会被**冻结整个进程生命周期**:better-auth 实例是懒创建的,但它读的是 init + 时定下的 config。所以标准组合下 auth 这一侧永远拿着「没有 cache」这个结论,限流计数器 + **从未**用上共享存储 —— 多节点部署的限额从来没有被全局强制过,每个节点各算各的,轮换 + 节点即可绕过。ADR-0069 D2 声明的能力与运行时不一致。 + + **修法:把「取 cache 服务」放回真正用到它的那一刻。** 新增 + `createLazyCacheRateLimitStorage()`,实现 better-auth 的 `rateLimit.customStorage`: + 计数器被消费时才解析 `cache` 服务(这一刻必然在 `kernel:ready` 之后,因此与插件启动 + 顺序无关),解析到就一直用它。告警保留,但只在**计数器真的要用共享存储、而此刻确实 + 一个 cache 服务都没有**时才打一次 —— 那时它才是真信号,「加一个共享缓存」也才是对的 + 建议。真没有 cache 的部署仍然限流,只是退化成进程内计数(降级,不是关闭)。 + + **刻意走 `rateLimit.customStorage` 而不是 `secondaryStorage`。** 后者会连带把**会话 + 的记录之处**搬进缓存:better-auth 的 `createSession` 不再写 `sys_session` 行, + `findSession` 直接从缓存快照作答、根本不查库;而 ADR-0069 D4 的空闲超时 / 绝对时长 + 上限 / 并发上限**全部靠写那一行来撤销会话**。所以自动把 cache 绑成 `secondaryStorage` + 会静默废掉 D4 的三个管控。本次因此不再从 cache 服务自动派生 `secondaryStorage`: + 它回归「宿主显式提供才生效」,`cacheSecondaryStorage()` 改为从包根导出,供知情的宿主 + 自行选用。会话到底该存哪,是一个需要维护者裁定的架构问题,记录在 #4785。 + + 对使用者的影响: + + - 配了 cache 插件的部署不再出现那条 warn,改为一条 info(计数器已绑定到 cache 服务); + - 多节点 + Redis cache 的部署,限流计数**现在真的**是全局的; + - 新增 `AuthManagerOptions.rateLimitStorage`(counters-only,不迁移会话);宿主自己 + 提供的 `secondaryStorage` 行为不变,仍然优先并继续走 + `rateLimit.storage: 'secondary-storage'`。 + +- 8bd437f: fix(plugin-auth): 每号码 OTP 发送预算改用惰性解析的共享计数存储 —— 多节点下不再按节点数倍增 (#4790) + + #2780 的「每号码 OTP 发送预算」(60s 冷却 + 每小时 5 条)此前**只有宿主显式提供 + better-auth `secondaryStorage` 时才跨节点共享**:`AuthManager.getOtpSendGuard()` 唯一的 + 存储来源就是 `AuthManagerOptions.secondaryStorage`,而标准 `serve` 组合里没有任何一处 + 提供它(#4788 之后 `AuthPlugin` 也明确不再从 cache 服务派生它)。于是预算落在**每个进程 + 一份**:N 个节点的部署,一个号码实际能收到的是声明值的 N 倍,而且**没有任何信号**告诉你 + 它没兑现(ADR-0049 声明 ≠ 强制)。这里的计价单位是**真金白银的短信**。 + + 这是 #4772 那条限流洞的同类,但是独立的一处:#4788 修的是 better-auth 自己的 `rateLimit` + 计数器(走 `rateLimit.customStorage`),OTP 预算是 ObjectStack 在 `AuthManager` 里自己实现 + 的另一套计数,行为未被 #4788 改变。 + + **修法:复用 #4788 建好的那条路径,而不是再写一份。** `rate-limit-storage.ts` 中把「惰性 + 解析 → 绑定即宣告 → 解析不到就降级到有界的进程内存储并响亮告警」抽成 + `createLazyCounterStore()`(`createLazyCacheRateLimitStorage()` 现在就是它的一层薄封装), + OTP 预算经由新的 `AuthManagerOptions.sharedCounterStore` 接同一条路径: + + - **存储在每次发送校验时才解析**,因此 `CacheServicePlugin` 晚于 `AuthPlugin` 注册也照样 + 绑定得上(插件启动顺序不再决定任何事)—— 这正是 #4772 冻结结论造成的那个洞; + - 配了 cache 的多节点部署,每号码预算**现在真的是一份**,换节点不会重新获得冷却额度; + - 没有 cache 服务的部署**仍然限额**,只是降级为进程内计数,并在第一次真正计数时打一条 + 点名代价的 warn(「an N-node deployment can send up to N× the configured number of PAID + SMS to one number」)—— 降级不是关闭,两种情况在日志里可区分(绑定打 info,降级打 warn)。 + + **刻意不引入 `secondaryStorage` 来修它**(#4785):那会把会话的记录之处搬进缓存,静默废掉 + ADR-0069 D4 的三个会话管控。宿主自己提供的 `secondaryStorage` 对这个预算仍然优先且行为不变。 + + 冷却与滚动小时窗的语义**未做任何改动**:计数依旧是按号码的时间戳滚动窗口,只是换了它所在的 + 存储。(固定窗口计数器无法表达「距上一次发送满 N 秒」,把它改成定窗会在窗口边界放行两倍突发 + ——用一种倍增换另一种倍增。) + + 对使用者的影响: + + - 新增 `AuthManagerOptions.sharedCounterStore`,`AuthPlugin` 自动填充,一般宿主无需感知; + - 新增导出 `createLazyCounterStore()` 与 `counterStoreFromKv()`; + - `OtpSendGuard` 新增 `resolveStore` 选项,原有的 `storage`(字符串 KV)选项保持可用。 + +- 5046afe: fix(plugin-auth): OTP 冷却按声明值真正生效 —— 发送历史的保留时长不再被硬编码的 1 小时截断 (#4808) + + `OtpSendGuard` 有**两个**维度:每号码「距上次发送至少 N 秒」的冷却(`cooldownSeconds`), + 和每号码「滚动一小时内至多 M 条」的上限(`maxPerHour`)。它们需要**不同**的时间窗,而此前 + 两者共用了同一个硬编码的一小时:发送历史按 1 小时剪枝、也按 1 小时写 TTL。 + + 于是把 `phoneOtp.cooldownSeconds` 配成**大于 3600** 时:配置被接受,没有校验错误,没有 warn, + 但冷却所依据的那条历史记录在 1 小时处就被丢掉了 —— 声明「两次发送间隔 2 小时」,实际最多 + 只有 1 小时,**反滥用强度是声明值的一半,而且没有任何信号**(ADR-0049 声明 ≠ 强制)。 + 计价单位仍然是真金白银的短信。这与 #4790 是同一个 guard 上的**不同**缺陷,且改动前后行为 + 一致 —— 不是 #4806 引入的。 + + **修法(issue 的方向 1):保留时长跟随配置。** 历史保留 `max(1 小时, cooldownSeconds)`, + 即「两个维度里还用得着它的那个更长的窗」;TTL 同步跟随,记录因此活得比它所度量的冷却更久。 + 每小时上限仍在**它自己的滚动一小时**内计数,所以超长冷却不会反过来把 `maxPerHour` 收得比 + 声明的更严。 + + **上限是拒绝,不是又一次截断。** `cooldownSeconds` 超过 `MAX_COOLDOWN_SECONDS`(86400, + 即 24 小时)会在**启动时**抛错(`AuthPlugin.init()` 构造 `AuthManager` 处),错误信息给出 + 值、上限和改法。把截断点挪到更高的数字只是把同一个缺陷往外推一个量级;设上限的理由是: + 一条号码的历史会在共享缓存里驻留整个冷却期,而超过一天的封锁已经不是发送节流而是账号锁定 + (另一套机制、另一套管控)。这条边界同时把「`cooldownSeconds` 误填成毫秒」这类笔误变成 + 一次响亮的拒绝(5 分钟以上的意图都会被挡下)。校验放在**配置处**而不是首次发送处:guard + 是惰性构造的,只在那里校验的话,一个配置错误会表现为 `/phone-number/send-otp` 的 500。 + + **默认配置行为完全未变**,并有测试锁定:未配置 `phoneOtp` 时仍是 60 秒冷却 + 每小时 5 条, + 历史保留与 TTL 仍是 3600 秒。 + + 对使用者的影响: + + - `phoneOtp.cooldownSeconds` 现在在 1 小时以上也真正生效(上限 24 小时); + - 超过 24 小时、负数或非有限值的配置**开始被拒绝**——这些值此前从未按声明工作过(要么被 + 静默截断到 1 小时,要么被静默钳成 0 即关闭冷却),因此不存在依赖其旧行为的部署; + - 新增导出:常量 `MAX_COOLDOWN_SECONDS` 与校验函数 `assertOtpCooldownSeconds()`。 + +- c03108c: fix(auth): a degraded tenancy posture must not hand out a default organization + + `TenancyService.defaultOrgId()` documented "returns `null` under any walled + posture", but the implementation keyed on the posture actually **in force** + (`isolationActive()`) rather than the one the operator **requested**. Those two + disagree in exactly one state — DEGRADED: a deployment that asked for `group` + or `isolated` and could not enforce it (the enterprise `@objectstack/organizations` + package is absent) reports `posture: 'single'`, and the resolver then happily + answered with "the `slug='default'` org, or the only org that exists". + + Everything downstream of that resolver binds new users to whatever it returns. + The membership reconciler (ADR-0093 D2) runs on `user.create.after` — the seam + every creation path flows through — so in a degraded deployment **every fresh + signup, admin-created user and SSO JIT user was auto-bound as a `member` of + whichever organization happened to be resolvable**, and `backfillMemberships` + (D6) would sweep the pre-existing member-less ones in on the next + `kernel:ready`. + + This reached production. ObjectStack Cloud's control plane runs + `OS_MULTI_ORG_ENABLED=true` while deliberately not mounting the enterprise + package — it enforces its own control-plane org wall instead — so the + `org-scoping` probe missed, the posture resolved degraded, and self-serve + signups landed inside a stranger's organization with read access to that org's + environments (cloud#957). + + `defaultOrgId()` now keys on `requestedPosture`: any walled request, enforced or + degraded, returns `null` and the framework never guesses. This is the same + judgement D6 already applies to the backfill — "a wrong org in a tenant-isolated + deployment is a data-exposure bug, not a convenience" — applied to the resolver + those consumers share. It also makes the resolver agree with the default-org + bootstrap in `AuthPlugin.start()`, which was already gated on the requested + posture. + + Single-org deployments are unaffected: nothing about `requested: 'single'` + changes. A degraded deployment loses the auto-bind, which is the point — and + ADR-0093 D5 already refuses to boot that deployment at all unless the operator + sets `OS_ALLOW_DEGRADED_TENANCY=1`. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [05d8a54] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [be90dea] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/rest@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 0b799c531e..e5b6dcea97 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.1", + "version": "17.0.0-rc.2", "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 f5d29d004e..a27486257d 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/plugin-dev +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [328ccc5] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [941dec4] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2eb850] +- Updated dependencies [8bd437f] +- Updated dependencies [5046afe] +- Updated dependencies [203a449] +- Updated dependencies [6dcbbc3] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [05d8a54] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [0d9a779] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [be90dea] +- Updated dependencies [04f1182] +- Updated dependencies [c03108c] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/plugin-auth@17.0.0-rc.2 + - @objectstack/plugin-security@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/rest@17.0.0-rc.2 + - @objectstack/service-storage@17.0.0-rc.2 + - @objectstack/driver-memory@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/plugin-hono-server@17.0.0-rc.2 + - @objectstack/account@17.0.0-rc.2 + - @objectstack/setup@17.0.0-rc.2 + - @objectstack/service-i18n@17.0.0-rc.2 + - @objectstack/service-realtime@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index e9fc1cd1bb..c249096be6 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.1", + "version": "17.0.0-rc.2", "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 1b1ac0f785..29f7bc91e5 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,176 @@ # @objectstack/plugin-email +## 17.0.0-rc.2 + +### Minor Changes + +- ce92674: feat(email): declared email templates reach the mail service (#4509) + + Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate` + resolves `(name, locale)` against **`sys_email_template` rows**, and the only + writers of those rows were the built-in auth templates plus a code-constructed + `EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every + door an author can actually use — a stack's `emailTemplates:`, an + `*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked + items in a metadata store nothing read back. So an admin could "fix" the + password-reset email in Studio, get a success toast, and watch users keep + receiving the built-in copy: ADR-0078 false compliance on **authentication + mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049 + enforce-or-remove, route: enforce). + + **`bootstrapDeclaredEmailTemplates`** now materializes declared templates into + `sys_email_template` at boot. Each item is validated through + `EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real + consumer, defaults and all — and projected with `mapTemplateToRow`, which is the + **same** mapping the built-in seeder uses, extracted and shared so the two doors + cannot drift apart. A malformed template warns and is skipped rather than + crashing boot. + + **Runtime writes take effect immediately.** Unlike `webhook`, `email_template` + is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio + save inert until the next restart — the same bug, half-fixed. The plugin also + subscribes to `email_template` metadata changes and re-materializes the single + changed item; withdrawing a template deactivates its rows (across locales) + rather than deleting them. + + **Three breaks sat on this path, not one**, and closing any two of them would + still have shipped a template that never sent: + + - `@objectstack/objectql` never registered a manifest's `emailTemplates:` into + the metadata registry at all — the key was simply missing from the generic + ingestion list, so the bridge's own source was empty. + - The built-in seeder left `managed_by` at the column's `'admin'` default, which + made platform templates masquerade as admin-authored. Since the bridge refuses + to overwrite admin rows, a built-in would have permanently outranked the + template an app declared. Built-ins now stamp `managed_by: 'platform'`. + - Nothing materialized declared metadata into rows. + + **Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule` + (#2909): `sys_email_template` gains `managed_by` / `customized`. Declared + templates re-seed every boot as `managed_by: 'package'`; a row an admin created + (`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never + overwritten, so reworded transactional mail survives redeploys. This is a + separate axis from `is_system`, which keeps its existing meaning for built-ins. + + The `email_template` liveness ledger flips from 13 dead properties to fully + live, with an ADR-0054 runtime proof bound on `subject` + (`email-template-materialization`): it boots a real stack, authors a template + that overrides a built-in auth template, and asserts the **authored** wording is + what reaches the transport. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 64bd04b568..706fdfb052 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.1", + "version": "17.0.0-rc.2", "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 9b8ae1f74c..52bc7e7255 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,174 @@ # @objectstack/plugin-hono-server +## 17.0.0-rc.2 + +### Minor Changes + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 6002cac699..6b9ee0c691 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.1", + "version": "17.0.0-rc.2", "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 4753e35e22..d9de213346 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,37 @@ # @objectstack/plugin-pinyin-search +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [ce5242c] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2445c9] +- Updated dependencies [462b713] +- Updated dependencies [63b33e6] +- Updated dependencies [a52e2ef] +- Updated dependencies [4c45be1] +- Updated dependencies [ce92674] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [127f091] +- Updated dependencies [071d0dc] +- Updated dependencies [1ee48bc] +- Updated dependencies [26bb053] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [ad5fe25] + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index bdeea9ecb5..373d0dfe96 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.1", + "version": "17.0.0-rc.2", "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 a2bd688320..bde60c1b78 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/plugin-reports +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 7f21e63733..22ddd3ee41 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.1", + "version": "17.0.0-rc.2", "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 dbfe14f919..7b32dfb41b 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,396 @@ # @objectstack/plugin-security +## 17.0.0-rc.2 + +### Minor Changes + +- ce5242c: feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586) + + better-auth owns every write to the identity tables (`sys_member`, `sys_user`, + `sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on + purpose** — the route already authorized the action under better-auth's own ACL, + and ADR-0092 D2 refuses user-context writes to those tables outright. The + consequence was that the human who clicked _make admin_ was known exactly once, + in the hook layer where the session exists, and then discarded: every + `trackHistory` transition on `sys_member` recorded `user_id: null` / "system", + and `sys_user_permission_set.granted_by` was written null by the auto-grant. + "Who made this person an org admin?" had no answer in the platform's own audit + log. + + **What changed** + + A request-scoped attribution seam, general rather than a `sys_member` special + case: + + | Layer | Before | After | + | :--------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | + | `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED | + | `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` | + | better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open | + | audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody | + | `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row | + + Outside a request scope nothing changes: writes stay bare `{ isSystem: true }` + and audit rows keep recording `null`. Absence is still never upgraded into a + caller, and never written as a sentinel string (ADR-0118 D1/D2). + + **Hard constraint — attribution is not authority** + + `attributedUserId` is read by exactly one consumer, the audit writer, and by no + security middleware. It never becomes `ExecutionContext.userId`, so it is never + the subject the engine authorizes as: not RLS `current_user`, not the ownership + stamp, not permission resolution. A context carrying only `attributedUserId` + authorizes exactly like an empty context (ANONYMOUS), and a context carrying it + beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing + identity writes as the human would re-adjudicate a decision better-auth already + made — the second adjudication track ADR-0095 D3 closed. The constraint is + pinned by tests at three layers: the engine seam + (`packages/objectql/src/engine.test.ts`), the better-auth adapter + (`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the + live HTTP route (a plain member still cannot promote themselves). + + **For authors and plugin developers** + + `attributedUserId` is authorable on `ExecutionContext` and readable as + `ctx.provenance?.attributedUserId` in hooks. Use it to answer _who is + responsible_; keep using `ctx.session` / `ctx.user` to decide _what is + permitted_. The two are separate fields precisely so the distinction cannot be + blurred by accident. + +- 328ccc5: fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437) + + Two defects on the analytics query path, both found by the v17 verification run + (#3909 / #4482), both reproduced against a live showcase server before the fix + and re-verified with the same requests after. + + ## #4467 — `/analytics/query` applied no record-level scoping + + `ISecurityService.getReadFilter` documents itself as "the same filter the engine + middleware AND-s into every find", and exists precisely for paths that bypass + that middleware — its own doc comment names the analytics raw-SQL path. But the + chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and + plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s + `buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`). + Only the RLS half was ever computed here, and analytics has no other source of + scope, so the OWD/share predicate simply never existed on that path. + + Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns + 5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`. + `GET /data/showcase_private_note` correctly returned 2 for the member, while + `POST /analytics/query {measures:['count']}` returned 5 — and adding + `dimensions:['title']` returned all five titles, i.e. the VALUES of a column + that caller may not read, not merely a bad count. Any authenticated caller who + could reach `/analytics` could enumerate the field values of every row of any + object exposed as a cube, regardless of OWD, sharing rules, or RLS. + + `getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the + late-bound `sharing` service and AND-composes it with the RLS filter — the same + composition the two middlewares reach by both writing into `ast.where`. It also + computes the ADR-0057 D1 `__readScope` depth that the security middleware + normally stashes on the context for plugin-sharing to widen its owner-match + with, using the same `getEffectiveScope` call the middleware makes: no + middleware runs on this path, and without it a caller granted `unit`/`org` read + depth would be silently narrowed to `own`. The sharing predicate is resolved for + every non-system caller AHEAD of the RLS stand-down branches, because those are + the RLS middleware's own early exits and none of them is a reason to drop a + sibling middleware's predicate; a sharing-resolution failure denies outright + rather than falling through to half a scope. + + **Why `minor` rather than `patch`.** This is an observable behaviour change on a + public read surface, in the narrowing direction: analytics results that a + principal could previously read they now cannot. Counts drop, `dimensions` + groupings lose rows, and any dashboard, report, or export built on + `/analytics/query` over an owner-private object will show smaller numbers for + non-superuser principals — correctly, but visibly. Deployments that had (however + unknowingly) come to depend on the unscoped totals will see them change on + upgrade, so this warrants more than a patch-level note even though it is a + security fix. No API signature changed: `ISecurityService.getReadFilter`'s + declaration is untouched — the implementation merely started honouring the + contract it already documented. + + ## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR + + `inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has + no way to know the field exists, so it built `SUM(ghost)`, the driver threw + `no such column`, and the caller got + `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error + class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A + dotted spelling took the same path (`measures:['total.sum']` prefix-strips to + `sum` → `SUM(sum)` → 500). The DATA route has refused the identical mistake with + a `400 INVALID_FIELD` naming the field since #4315/#4254. + + `AnalyticsService.ensureCube` now validates each measure's resolved source field + against the backing object's field names before any SQL is built, and rejects + with the same envelope the data route produces (`400 INVALID_FIELD` carrying + `field`, `object`, `param`, `measure`) so one mistake has one shape across + `/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the + same schema registry `isRegisteredObject` already consults and the data path's + own gate reads, so "which fields exist" has a single answer across both routes. + + The gate is tiered exactly like the #3867 cube-inference gate, deliberately + narrow: it applies only when the cube's `sql` is a bare object name (an authored + cube whose `sql` is a real SQL expression has no field list to check against), + only when the probe answers (no data engine, or an external datasource whose + columns are not mirrored locally, stands down), and only to measures whose + source is a bare column — `count(*)` has no source field, and a dotted + cross-object reference resolves through a join this layer cannot see, so both + pass through untouched. `id`/`created_at`/`updated_at` are admitted + unconditionally, matching the data path's `resolveQueryFields`: a gate stricter + than the engine it guards would reject queries that used to work. Validation + runs before the cube is registered, so a rejected query leaves no trace in the + registry — otherwise a retry would find a "registered" cube carrying the bogus + measure and sail straight into SQL. + + This half is `minor` for the same envelope reason: a request that used to return + 500 now returns 400 with a different `code`, which is a visible contract change + for any caller branching on the response. + +- 6dcbbc3: fix(plugin-security): the org-admin auto-grant can actually revoke — demoted admins really do lose tenant admin (#4640) + + `auto-org-admin-grant`'s only delete channel called + `ql.delete(object, id, { context })`. The engine's signature is two arguments — + `delete(object, options?: EngineDeleteOptions)` — so the id landed in the option + bag, `rejectUnknownEngineOptions` read its character indices (`'0'`, `'1'`, …) + as unknown option keys and threw, and `tryDelete`'s `catch` swallowed it. The + system context in the discarded third argument went with it. + + That wrapper is the module's **only** delete channel, so all three revoke paths + were silent no-ops for the module's entire life: + + 1. **Demotion and member removal did not take the capability back.** + `organization/update-member-role` moving someone from `owner`/`admin` back to + `member` reconciled, deleted nothing, and returned + `{ action: 'skipped', reason: 'delete_failed' }` while the + `sys_user_permission_set` row stayed put. That row carries wildcard + `viewAllRecords`/`modifyAllRecords` → `isTenantAdmin()`, so the demoted user + remained a **tenant admin**. + 2. **The ADR-0105 D4 superseded-variant convergence never converged.** A posture + change left the old `organization_admin` / `organization_admin_no_bypass` row + in force — on a wall-less deployment, that is the unbounded variant. + 3. **The `kernel:ready` orphan sweep never swept** (membership deleted, grant + left behind). + + The call now matches every other `ql.delete` call site in the repo: + `ql.delete(object, { where: { id }, context: SYSTEM_CTX })`. + + ## ⚠️ Behaviour change: people will lose tenant admin on upgrade — that is the fix working + + Existing deployments have accumulated `sys_user_permission_set` rows that should + have been revoked when someone was demoted or removed from an organization. + After this release the `kernel:ready` backfill reconciles them, and every one of + those grants is deleted on the first boot. Concretely, on upgrade: + + - users demoted from `owner`/`admin` to `member` at any point in the past + **stop being tenant admins**; + - users whose membership was deleted lose their orphaned org-scoped grant; + - deployments that changed `tenancy.posture` converge on the posture's variant + instead of keeping both. + + Nobody loses access they were _supposed_ to have: the grade that qualified them + was already taken away, and only the capability row outlived it. If a specific + person should keep blanket visibility, grant it deliberately — + `admin_full_access` or an explicitly authored permission set — rather than + through a better-auth membership grade. Expect `[security] revoked org-admin +capability` lines in the boot log naming each one. + + Failed revokes are no longer silent either: a delete the datastore rejects logs + `[security] org-admin grant revoke FAILED — capability still in force`, and a + reconcile that found grant rows and removed none logs that it left them behind. + A capability the platform decided to withdraw and could not is exactly the + outcome that must reach an operator. + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +### Patch Changes + +- 0d9a779: fix(security): 让 permission-set 投影只写 spec 认的键,并把静默失败的 backfill 变响亮 (#4669) + + ADR-0094 D4 的 permission-set backfill 在 #4001 之后 **100% 失败**:`sys_permission_set` + 每一行都有 `active` 存储列,`permissionSetBodyFromRow()` 把整行转成 metadata body 时把它 + 一起带上,而 #4001 已经把 `PermissionSetSchema` 封成 `.strict()` —— 于是每一次 + `saveMetaItem` 都抛 `[invalid_metadata] … Unrecognized key(s) on this permission set: +'active'`。失败被 `catch` 成一条 `warn`、计数器不加一,所以测试全绿、没有任何自动信号: + 一个整条停摆的投影路径就这样过了一个发布周期。 + + **归属判定:`active` 是行状态,不是声明。** 它的全部消费面 —— 表列、`highlightFields`、 + Setup 列表视图的过滤器、两个启停动作的 `bodyExtra: { active: … }` —— 都是记录的运行时开关, + 不是作者声明的能力边界。所以修法是在**投影侧挑键**,而不是把状态提升进 spec + (`packages/spec/**` 零改动)。 + + - `permissionSetBodyFromRow()` / `mergeRowPatchIntoBody()` 现在都经过一个**从 + `PermissionSetSchema.shape` 派生**的键白名单(不是手抄的字符串数组 —— 手抄的话 spec 加键 + 时这里又会静默漏,正是本 bug 的翻版)。存储列(`active`、时间戳、`managed_by` / + `package_id` / `customized`)一律不进 metadata body;`#4001` 之前**已经落库**、body 里 + 仍带着 `active` 的历史 overlay 行,也在同一个闸口被滤掉,因此它们的数据门编辑不再报 422。 + - 两个启停动作行为不变:只含行状态的 PATCH 不再被改写成 metadata 写入,而是原样交给驱动 + 执行列写入(保留 history / `updated_at` / FLS 等正常语义),并且不会再给一个包自带的 + permission set 平白造出一条“customization” overlay。投影通道则不再从 body 读 `active` —— + 一次投影不会再用陈旧 body 把管理员刚停用的 set 重新打开。 + - backfill 真失败时按 AGENTS.md「Degradation log levels」(#4632) 变响亮:`error` 级、 + 文案写明后果(记录照常列出、看起来一切正常,但定义不在 metadata 里,重新 provision 不会 + 重建它)与修复动作,并新增 `ProjectionReconcileOutcome.backfillFailed` 计数,让降级出现在 + 结果里而不只在日志里。 + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 93083e21cc..fb78ee43ca 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.1", + "version": "17.0.0-rc.2", "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 fe0eb9be4c..8314be02c2 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,254 @@ # @objectstack/plugin-sharing +## 17.0.0-rc.2 + +### Major Changes + +- 4b6cac7: feat(spec)!: resolve the three cross-form dual-source names — ShareRecipientType, TransformType, suggestFieldType (#4539) + + Three `dual-source-exports.baseline.json` rows where the two declarations + sharing a name did not even share a FORM (type vs const, or two unrelated + functions), so a wrong import-path pick had no shape overlap to hide behind + and failed far from the cause. Each judged against a three-repo import-level + scan (framework, cloud, objectui — the latter two contained zero references + to all three names). All three rows are deleted from the baseline. + + **Renamed — `./contracts` `ShareRecipientType` → `RecordShareRecipientType`:** + + Two live concepts shared the name. The security zod enum + (`user | team | position | unit_and_subordinates | business_unit`) is the + authorable sharing-RULE recipient vocabulary and keeps the name. The contracts + type describes a different thing — the `recipient_type` a `sys_record_share` + ROW may carry — and its claim to "mirror spec/security" had been false since + `group`→`team`/`guest` were retired there. Its member set is now aligned to + the storage-side gate it actually mirrors, the `SysRecordShare` + `recipient_type` select: `role` (never persistable, zero producers) is + replaced by `position`. Only `user` is enforced (and written) today; + `ISharingService.grant` keeps refusing every other value (ADR-0078). + Fix: `import type { ShareRecipientType } from '@objectstack/spec/contracts'` + (or from `@objectstack/plugin-sharing`, whose re-export is renamed in + lockstep) → `RecordShareRecipientType`; code that named the `'role'` member + was describing a value no row could ever hold — use the rule vocabulary + (`SharingRuleRecipientType`) if a role recipient was meant. + + **Renamed — `./shared` `TransformTypeSchema` / `TransformType` → + `FieldMappingTransformSchema` / `FieldMappingTransform`:** + + `./data`'s `TransformType` (the authorable import-mapping enum + `none | constant | lookup | split | join | javascript | map`) is the live + declaration and keeps the name. `./shared` exported `TransformType` as the + inferred type of `TransformTypeSchema` — a differently-shaped discriminated + union of transform CONFIG objects — with zero importers for either name in + all three repos. The shared pair is renamed (not just the alias deleted): + the docs generator derives `import type { X }` examples by stripping + `Schema` from each schema const, so an alias-less `TransformTypeSchema` + would have kept generating a reference to an export that no longer exists. + Fix: `TransformTypeSchema` → `FieldMappingTransformSchema`, + `import type { TransformType } from '@objectstack/spec/shared'` → + `FieldMappingTransform` (same shape); importers who meant the import-mapping + enum import `TransformType` from `@objectstack/spec/data`. + + **Renamed — `./data` `suggestFieldType` → `suggestFieldTypeForSqlType`:** + + The only function-kind dual-source. The two implementations were never forks + of one function — different signatures, semantics and return types: + `shared/suggestions.zod.ts` (kept on `.` / `./shared` under the original + name) is the typo-suggester for an invalid authored FieldType + (`(input: string) => string[]`, alias table + Levenshtein, feeds the zod + error map), while `data/type-compat.ts` is the deterministic SQL-column → + FieldType mapper for external-datasource drafts + (`(rawType, dialect?) => FieldType | undefined`, ADR-0015 §4.6). Same input, + divergent outputs — `('varchar(255)')` → `[]` vs `'text'`; `('text_area')` → + `['textarea']` vs `undefined`; `('int')` → `['number']` vs `'number'` — and + the wrong pick compiled wherever the result was only truthiness-checked + (`[]` is truthy). Behavioral divergence is now pinned in + `data/type-compat.test.ts`. + Fix: `import { suggestFieldType } from '@objectstack/spec/data'` → + `suggestFieldTypeForSqlType` (same signature); imports from the root entry + or `./shared` are unaffected. + +### Minor Changes + +- ba5ff2f: fix(plugin-sharing): deactivating or deleting a sharing rule actually withdraws its grants (#4433, #4434) + + An over-granting sharing rule had no withdrawal path on the product's API + surface. Deactivating it left every grant it had materialised in place — not on + the next record touch, not after a full restart — and the DELETE route answered + 500 for both address forms it advertises, so the rule could not be removed + either. Together that made a too-broad rule unrecoverable short of hand-editing + `sys_record_share`, against a v17 release note that advertises the opposite + ("switching a rule off actually withdraws access"). + + `minor`, not `patch`: this changes an observable runtime behaviour that + deployments may have adapted to. A `source: 'rule'` grant whose rule is + inactive — or whose rule row is gone — now disappears, on the deactivating + write, on the next touch of the record, and on the next boot. Anything relying + on those rows surviving deactivation (including data repaired by hand around + the old behaviour) will see them revoked on upgrade. `DELETE +/api/v1/sharing/rules/:idOrName` also starts succeeding where it used to 500, + so callers that treated that 500 as "unsupported" will now really delete. + + #4433 — three independent gaps, one per path the report walked: + + - **The deactivating write.** The `sys_sharing_rule` reconcile trigger skipped + every `isSystem` write, on the theory that those were boot seeding. + `SharingRuleService.defineRule` — the only implementation behind + `POST /sharing/rules`, and the documented way to deactivate a rule — writes + with SYSTEM_CTX unconditionally, because it must reach a platform table the + sharing middleware otherwise gates. So the skip caught 100% of REST + authoring: the withdrawal path built by #3821 existed, had tests (against a + mocked session the real path never sends), and was unreachable in production. + Now gated on boot phase, which is the question the skip actually meant to + ask. + - **The record touch.** `evaluateAllForRecord` listed only active rules, so a + deactivated rule was absent from the loop entirely and its grants were never + examined. It now reconciles every rule; an inactive one desires nothing and + takes the existing revoke-the-remainder branch. + - **The boot pass.** `backfillRuleGrants` was handed an `activeOnly` list, + making it structurally incapable of revoking anything. It now walks every + rule, and a new `sweepOrphanedRuleGrants` retires grants whose rule row is + gone entirely — unreachable by rule iteration, so they need their own sweep. + + #4434 — `deleteRule` purged `sys_record_share` with a predicate-shaped + `engine.delete` carrying neither a scalar id nor `multi: true`, the one shape + the engine's dispatch refuses; it threw before ever reaching the rule row. + Fixed by routing through the same `SharingService.revoke` path every other + withdrawal already uses, rather than adding `multi: true` — a rule's grants now + retire exactly one way instead of two divergent ones. + + The unit fakes are part of the fix: `makeEngine().delete` accepted any `where`, + which is why #4434 shipped green — the pre-existing "deleteRule drops rule and + all its grants" test asserted success against a delete the running server + always rejected. The fakes now mirror the real engine's dispatch guard. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index f5a38fab01..12d8d44adc 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.1", + "version": "17.0.0-rc.2", "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 84aaef89d1..f9988e1acc 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,255 @@ # @objectstack/plugin-webhooks +## 17.0.0-rc.2 + +### Minor Changes + +- f2445c9: feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) + + A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, + which are contracted to resolve an affected row COUNT and nothing else. That + satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / + `changes`, so before #4626 the engine fabricated a per-record event with + `recordId: ''` and `after: ` — an event every schema-compliant consumer + must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into + a real delivery naming an unidentifiable record. #4626 removed the fabrication + and published nothing instead: honest, but it left webhooks, knowledge sync and + `subscribeData` silent for every predicate write. + + Bulk writes now get their **own** contract rather than impersonating a + per-record one or going dark: + + - **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from + `DataEvent`, not a widened one: a consumer that receives + `data.records.updated` knows from the type alone that no `recordId` is + coming, instead of discovering an empty string at runtime. + - **Engine** publishes it from the `multi: true` branches of `update()` / + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A + predicate that matched **zero** rows publishes nothing (no data changed — this + is what keeps an idle background sweep from becoming an hourly "0 records" + delivery), and a driver that resolves a non-count publishes nothing and warns + rather than asserting a number it cannot verify. Per-record writes are + untouched, including a scalar `where.id` with `multi: true`, which is still a + single-record target and still emits `data.record.deleted`. + - **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are + **not** extra sources for `create` / `update` / `delete`: the delivered body + has no `recordId` and no record, so routing it to existing per-record + subscribers would hand them a payload missing every field they read — the + same class of breakage as the old `recordId: ''`, from the other direction. A + webhook that wants both subscribes to both. Bulk deliveries dedup on the + producer's event uuid, since two sweeps in the same millisecond are genuinely + different events that a timestamp-based key would collapse. + - **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the + same loud boundary validation as `subscribeData`. Kept a separate method for + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => +void` callback would recreate exactly the "typed field, `undefined` at + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened + from `data.` to `data.record.`, so an aggregate event is ignored rather than + rejected as off-contract. + - **Knowledge sync** now says out loud that a predicate write leaves its index + stale. A knowledge index is a per-record projection and `matched: 40` names no + record, so no event shape could drive it — the durable fix is reconciliation, + tracked in #4672. + + The event carries no `where` predicate. The only one available at publish time + is the middleware-composed AST, whose filter embeds the security layer's + injected row scoping (RLS, sharing) — publishing it would ship tenant scoping + internals to whatever external URL a webhook points at. + + Also pays off a measurement debt from #4655, which claimed the write-path cost + of event publishing had been measured but never published the numbers: + `packages/objectql/src/engine-data-events.bench.ts` measures it. Against an + in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, + single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless + of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its + relative cost shrinks as the match set grows. + +### Patch Changes + +- 257d97a: ADR-0078 Phase 4, decided rather than deferred: the silent skips stop being silent at runtime. The registry — the one choke point every metadata door goes through — now emits a functional-completeness diagnostic at registration, and the webhook enqueuer's zero-trigger skip warns instead of returning `null` wordlessly. + + **The Phase 4 ruling.** The phase had two halves, and they got opposite verdicts: + + - **Generative rule sweep: rejected — not deferred.** A generator can enumerate candidates ("which optional keys might be load-bearing?") but cannot verify runtime skip sites, and a rule without its skip-site citation is a false prescription — this campaign shipped four of those and every one was caught by the verification pass a generator would skip. The route is structurally wrong; no amount of waiting produces the evidence that would fix it. + - **Registration-time diagnostics: built now.** The evidence was already in hand, not pending: #3896 (Setup authoring inserted `sys_sharing_rule` rows directly, bypassing the schema that "required" `criteria`) and cloud's `rowColor.mapping` (an `as never` cast bypassed tsc) prove that doors which skip Zod and lint are real. The author-time gate only protects metadata that passes through `os build` / `validate` / `lint`; `SchemaRegistry.registerObject` is where _every_ door converges — declared stacks, plugin objects, `extend` contributions, `saveMetaItem`, raw `registerObject` calls. + + **Same predicate, same rule ids, different posture.** The registry calls the same `checkFieldCompleteness` that `validate-functional-completeness` uses, so the boot log carries the _same rule ids_ the lint reports (`field/summary-without-operations`, …) — an operator or an AI reading the log greps the id straight into the same docs and suppression story. But the registry **warns and never throws**: ADR-0078 §1's error severity means _the instance is dead_, not _the system is dead_ — an inert field must not kill a boot that thousands of healthy objects share. Errors block at author time; the registry's job is to make sure the silence never survives to runtime unobserved. + + One line per object with every finding aggregated (not per request — the hot path stays free; not per finding — a three-dead-field object is one greppable line). Follows `warnStrippedLegacyApiMethods` (#3543) exactly: module-level once-per-object dedup, injectable `warn`, pure observation that never mutates the schema. + + **The webhook skip now names itself.** `auto-enqueuer.ts`'s `if (triggers.size === 0) return null` sat under a comment blessing the empty case as "a manual-only webhook" — a mode #3196 removed (no manual fire path exists). The skip now warns with the author-time rule id (`webhook/without-triggers`), and the comment tells the truth. Only _active_ rows reach the parse (`where: { active: true }` — verified, not assumed), so a deliberately disabled webhook stays warning-free. + + **Scope honesty:** field rules and the webhook rule get the runtime twin. `view/layout-without-binding` stays author-time-only — views don't register through this choke point and the renderer half of the evidence lives in objectui. + + Tracked in #4544. This closes the ADR-0078 loop end to end: author-time error, runtime warning, one shared predicate deciding both. + +- 462b713: fix(objectql,client): `subscribeData` callbacks receive real `DataEvent`s — the producer now fulfils the declared contract (#4626) + + `@objectstack/spec/api`'s `DataEvent` declares top-level `id` (uuid, + required), `type`, `object`, `recordId` (required), `changes?`, `before?`, + `after?`, `userId?`, `timestamp`. But the producer (the ObjectQL engine) + published a raw `RealtimeEventPayload` envelope with `{ recordId, after, +changes }` nested under `payload` and never generated `id`/`userId`, while the + client SDK force-cast that envelope into the callback (`callback(event as any +as DataEvent)`). Subscribers who wrote `event.recordId` / `event.changes` — + exactly what the types promised — compiled green and read `undefined` at + runtime. The data-side twin of #4602. + + Producer now fulfils the contract: + + - `ObjectQL.insert()` / `update()` / `delete()` build a true `DataEvent` + (generated uuid `id`, flattened top-level fields, `userId` from the + execution context when the write names an actor) and validate it with + `DataEventSchema.parse` before publishing. The transport envelope is + unchanged (`RealtimeEventPayload`, with `payload` carrying the complete + `DataEvent`), so subscribers keep receiving `{ type, object, payload, +timestamp }` on the wire. + - A batch insert publishes one event **per record** (as before), each with its + own event id. + - **A multi-row write (`multi: true` → `updateMany` / `deleteMany`) now + publishes nothing.** Those driver methods return only an affected count, so + there is no record for a required `recordId` to name; the engine logs a + warning naming the gap instead of publishing the previous fabrication + (`recordId: ''`, `after: `), which every schema-compliant + consumer had to reject. **Consequence: webhooks and knowledge sync no longer + fire for bulk writes** — they previously fired once with an unusable body. A + real bulk event contract is tracked in #4639. + + Consumers validate or read the fulfilled shape instead of guessing: + + - `@objectstack/client`'s `subscribeData` (and therefore + `@objectstack/client-react`'s `useDataSubscription` / + `useDataSubscriptionCallback` / `useAutoRefresh`, which delegate to it) + unwraps the envelope and runs `DataEventSchema.safeParse` at the boundary. + An off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as DataEvent` + double-cast is gone, and the `recordId` option now filters on the fulfilled + event. + - `@objectstack/plugin-webhooks`' auto-enqueuer reads the required + `recordId` directly; its `recordId ?? id ?? after?.id ?? before?.id ?? +'unknown'` fallback chain is gone, and an off-contract event is dropped with + a warning rather than delivered under the literal id `'unknown'`. Delivered + webhook bodies now also carry the event's `id`/`type`/`userId`; the record + itself stays nested under `after` and the envelope keys (`object`, + `recordId`, `action`, `timestamp`) still win. + - `@objectstack/service-knowledge`'s event sync reads the record from `after` + (create/update) and the id from `recordId` (delete) for `data.record.*`. + It previously indexed the envelope itself as if it were the row, and never + resolved an id for deletes. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [040ecd2] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/service-messaging@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index a37038c7f4..3a8bfa3e34 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.1", + "version": "17.0.0-rc.2", "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 9bc30a5124..895c3ca43f 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,165 @@ # @objectstack/dogfood +## 0.0.40-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [2f05139] +- Updated dependencies [fa94b2c] +- Updated dependencies [328ccc5] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [941dec4] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2eb850] +- Updated dependencies [8bd437f] +- Updated dependencies [5046afe] +- Updated dependencies [203a449] +- Updated dependencies [6dcbbc3] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [d449b0c] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [040ecd2] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [0d9a779] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [ba5ff2f] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [be90dea] +- Updated dependencies [04b9776] +- Updated dependencies [04f1182] +- Updated dependencies [c03108c] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [c2a1134] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/plugin-auth@17.0.0-rc.2 + - @objectstack/plugin-audit@17.0.0-rc.2 + - @objectstack/plugin-security@17.0.0-rc.2 + - @objectstack/plugin-webhooks@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/service-analytics@17.0.0-rc.2 + - @objectstack/service-storage@17.0.0-rc.2 + - @objectstack/example-crm@4.0.92-rc.2 + - @objectstack/example-showcase@0.3.14-rc.2 + - @objectstack/plugin-sharing@17.0.0-rc.2 + - @objectstack/plugin-email@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/verify@17.0.0-rc.2 + - @objectstack/service-messaging@17.0.0-rc.2 + - @objectstack/connector-mcp@17.0.0-rc.2 + - @objectstack/connector-openapi@17.0.0-rc.2 + - @objectstack/connector-rest@17.0.0-rc.2 + - @objectstack/mcp@17.0.0-rc.2 + ## 0.0.40-rc.1 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index d6a18827d7..31f43e70fe 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.1", + "version": "0.0.40-rc.2", "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 ed43c0de81..dda422c047 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,115 @@ # @objectstack/downstream-contract +## 0.0.38-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 0.0.38-rc.1 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 439517ccf7..7cd94453c1 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.1", + "version": "0.0.38-rc.2", "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 6ca0462c7f..fb7067b65e 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/http-conformance +## 0.0.6-rc.2 + +### Patch Changes + +- Updated dependencies [98877c9] +- Updated dependencies [071d0dc] + - @objectstack/core@17.0.0-rc.2 + ## 0.0.6-rc.1 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 550afb3852..b6dccb4f5a 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/http-conformance", - "version": "0.0.6-rc.1", + "version": "0.0.6-rc.2", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index 5d4fa2f250..25fb971a8f 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,321 @@ # @objectstack/rest +## 17.0.0-rc.2 + +### Minor Changes + +- 05d8a54: fix(rest)!: 服务端权威闸门现在也过滤 `areas[].navigation` —— area 内导航项的权限/能力闸门不再只是渲染层的礼貌 (#4722) + + `filterAppForUser` 是 `/meta` 上 app 元数据的**服务端权威可见性闸门**,但它此前只走 app + 的顶层 `navigation` 树:读到 `item.navigation` 不存在就原样返回,`item.areas` 从头到尾没被 + 读过。后果是,写在 **area 内部**导航项上的 `requiredPermissions` / `requiresService` + 只有客户端 `NavigationRenderer` 会执行 —— 该条目连同它的 `objectName` / `pageName` / + `componentRef` 指向,照常出现在 `/meta` 响应体里。改一次前端状态、或者直接读 `/meta` 的 + JSON,就能看到本该被 gate 掉的条目。对 areas 型 app 而言,导航项级闸门此前**不是**服务端强制。 + + **现在**:同一个 `filterNav` 被复用到每一棵 `areas[].navigation` 上 —— 不是第二份实现, + 所以两棵树对同一个键的语义不可能漂移。列表 `GET /meta/apps` 与单项 `GET /meta/apps/:name` + 两条路径都覆盖(两者都经过这个函数;单项读对 app 类型本就绕过缓存)。 + + **响应形状收紧(可能影响消费方)**:无权限用户拿到的 app 元数据里,被 gate 掉的 area 内 + 导航项**不再出现**。被闸门滤空的 area 整个剥离 —— 与顶层树对「被滤空的 group」的既有处理 + 同形(空壳标签没有消费价值);作者本就写成空的 area 原样返回(过滤只报告调用方看不到什么, + 不负责整理元数据)。任何依赖「服务端会把 area 内条目全量下发、由客户端自己藏」的消费方需要 + 改为信任服务端已过滤后的树 —— 这正是本次收紧的目的。 + + 同一提交修正了 `resolveRegisteredServices` 的探测面:它此前每个节点只取第一个命中的子数组 + (`navigation` / `children` / `widgets` 三选一),不会下钻 `areas`。若不改,只在 area 内被 + 引用的服务名不会被探测,而未探测的名字在闸门看来等同于「服务不存在」,会把一个本该存活的 + 条目误剥离 —— 探测面必须与过滤面完全一致。 + + **明确不做**:`visible`(CEL)在任何层级仍然只在客户端求值 —— 服务端求值需要绑定 `user` + 上下文,不是这个读路径现有的能力,另立单处理。这个不对称写进了代码注释、`packages/spec/liveness/app.json` + 的账本 note,以及 `rest.test.ts` 的 characterisation pin。必须永不到达浏览器的东西,写 + `requiredPermissions`,不要写 `visible`。#4651 退役的 **area 级**键(`areas[].visible` / + `areas[].requiredPermissions`)未被复活:本次强制的是 area **里面**的项级闸门。 + +- 8aacf94: feat(rest,runtime,client): `POST /meta/_migrate-stored` — run the stored-metadata migration without a shell (#4327) + + `os migrate meta --stored` (#4327) gave ADR-0087's stored-metadata chain a finish + line, but only for someone who can reach the deployment's database from a + terminal. A hosted operator cannot, so on a managed deployment the chain had no + finish line at all — just the per-read conversion, running forever, with no way + to assert what protocol the rows are on. + + The same pass is now reachable over HTTP: + + ```ts + const preview = await client.meta.migrateStored(); // writes nothing + const result = await client.meta.migrateStored({ apply: true }); + const flows = await client.meta.migrateStored({ types: ["flow"] }); + ``` + + It returns the same `StoredMigrationReport` the CLI renders, and takes the same + posture: + + - **Preview by default.** `apply` must be literally `true`; an empty body, a + missing body, and `"apply": "yes"` all preview. Nothing is inferred. + - **Gated on `manage_metadata`.** Unlike the single-item `PUT /meta/:type/:name` + next door, this rewrites every eligible row in the deployment, so it demands + the ADR-0066 D1 authoring capability rather than just a session, and answers + `403` otherwise. The gate runs before the protocol is probed, so an + unauthorized caller cannot use `403`-vs-`501` to learn which kernels can be + migrated. `/meta`'s anonymous-deny umbrella still closes it to anonymous + callers first. + - **Attributed to the caller.** The `actor` recorded on the history and audit + rows names the user who fired it — that is the question those rows exist to + answer. + + **Flows need no extra setup on this path.** The CLI has to boot an inert + automation engine to hold the executor registry ADR-0078's conflict guard needs; + a server already has a live one, and the protocol resolves it from the services + registry itself (#4498), so this route covers flow rows by simply running in the + process that owns them. + + Registered on both the REST server and the runtime dispatcher's `/meta` domain, + ledgered in both route ledgers, and mounted before `/:type` so the + leading-underscore segment is never captured as a metadata type name. + +### Patch Changes + +- 2826d1e: fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + + A flow paused at an `approval` node, a deploy, then an approver clicking + Approve: the request row flipped to `approved`, the UI toasted success — and + the flow never moved. No next-stage request, no error, the record's mirrored + status frozen mid-workflow. Approval flows pause for days by design, so a + restart mid-flight is the normal case: every release could quietly zombify + every in-flight approval, with the approvers none the wiser. + + Durable suspended runs (#1518) had shipped and were not the missing piece. Two + other things were. + + **The wiring could enable a store over a table nobody had created.** Object + registration and store activation resolve different services in different + phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin + declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, + warned, and continued; `start()` then attached the DB-backed store anyway. Every + suspend failed with `no such table: sys_automation_run` into a log line nobody + read, pauses silently stayed in memory, and the next restart lost them all. + Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an + engine-less kernel must still boot); a registration missed at `init()` is + retried at `start()`, which still lands before ObjectQL's schema sync; the + store is never attached when registration did not happen, and says so at + **error** level instead of warning; the table is probed once at boot so a + broken setup surfaces there rather than one failed write at a time; and a + failed durable write of a paused run is logged at error — it is data loss in + waiting, not a warning. + + **A reported resume failure read as success.** `AutomationEngine.resume()` + answers a lost run by _returning_ `{ success: false }`, never by throwing. + `ApprovalService` discarded that return value, and `decide()` counted only a + thrown error as failure — so a decision against a dead run came back + `resumed: true`, HTTP 200. Resume failures are now classified + (`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining + `PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is + distinguishable from a store that is merely unreachable, and the raw resume + route maps them to 404 / 503 / 409. + + Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which + reads the suspension store, unlike `getRun()`, and throws rather than answering + `false` when the store is unreadable — pre-flights every flow-advancing + operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the + zombie half-state is never created rather than merely reported: the decision + fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A + resume that fails after the decision is durable can no longer be undone, but it + now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of + reporting success. A concurrent duplicate resume stays benign — the engine's + idempotency guard is doing its job — and reports through the new optional + `resumeError` field. Recall and revise-window cancellation stay non-fatal by + design (they abandon the request), but log at error with the reason instead of + swallowing it. Compositions with no automation engine attached are unaffected. + + Existing zombie requests from affected deployments (already `approved`, run + stranded) are not repaired by this change — `releaseDeadRunRequests` only + sweeps requests that are still `pending`. + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- be90dea: fix(plugin-audit,rest)!: `sys_comment` derives its access from the record its thread names (#4630) + + Attachments derive their visibility from the parent record; comments derived + nothing. On the _same_ record, with the _same_ user, the two answered + differently: + + ``` + user: rep2 (does NOT own and cannot read the opportunity) + GET /api/v1/data/crm_opportunity?$filter=["id","=","1A7n…"] → 200, 0 rows + GET /api/v1/data/sys_attachment?$filter=["parent_id","=","1A7n…"] → 200, 0 rows + GET /api/v1/data/sys_comment?$filter=["thread_id","=","crm_opportunity:1A7n…"] + → 200, 1 row + POST /api/v1/data/sys_comment {"thread_id":"crm_opportunity:", …} → 201 Created + ``` + + `sys_comment` is public, has no owner column, and hides its parent inside a + string (`thread_id` = `{object_name}:{record_id}`), so neither OWD/sharing nor + RLS ever narrowed it. Because `enable.feeds` is opt-OUT (spec default `true`), + every object in every app carried that org-wide readable, org-wide writable + side-channel — a deployment that carefully authored OWD, sharing rules and RLS + on its records still leaked their discussion. + + `AuditPlugin` now installs the same two-part kit `service-storage` installs for + `sys_attachment`, keyed off `thread_id`'s parent: + + - **read** — a `find`/`findOne`/`count`/`aggregate` middleware intersects every + query with the threads whose record the caller can actually read (resolved + through the caller-scoped engine, so the parent's own OWD/sharing/RLS/CRUD + decide). `count()` is filtered identically to `find()`, so a list `total` + cannot leak the hidden rows' existence either. + - **write** — `beforeInsert` requires READ on the record the thread names; + `beforeUpdate` / `beforeDelete` require the caller to be the comment's AUTHOR + or to hold EDIT on that record. `author_id` is server-stamped from the + session, so a client-supplied value never wins. + + Everything fails CLOSED: a `thread_id` that names no record — the dangling + `"crm_opportunity:"` above, a free-form thread, a thread on `sys_comment` + itself — is refused on write and excluded on read, and a filter that cannot be + computed denies all rather than falling open. Refusals answer **403 + `RECORD_NOT_ACCESSIBLE`** (the standard error catalog, per ADR-0112 — a generic + permission condition takes a catalogued code rather than a new synonym), with + `error.object` naming the record's object. + + **Breaking for deployments that depended on the gap.** Reads that used to + return other people's comments now return fewer rows (or none), and writes that + used to 201 now 403. Specifically: + + - Listing `sys_comment` without being able to read the parent record → the row + is gone, not merely unlabelled. Panels that render a thread must be reached by + a principal who can read the record. + - Threads whose `thread_id` is not `{object_name}:{record_id}` are no longer + usable at all: creating one is refused, and existing rows become invisible to + everyone but system context. Migrate free-form threads to a real record + reference (or keep them under a system-context surface). + - Deleting or editing another user's comment now requires EDIT on the record. + Note also that `sys_comment` delete already needed a permission set carrying + `allowDelete` — the `member_default` baseline has none (ADR-0090 D5). + - Posting a comment no longer requires the client to send `author_id` (it is + stamped); a client that sends someone else's is silently corrected rather than + believed. + + Orthogonal and unchanged: `enable.feeds` (`FEEDS_DISABLED`) still gates whether + an object has comments at all, and anonymous callers are still refused with 401 + before any of this runs. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/service-package@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes @@ -145,8 +461,8 @@ 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 + 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. diff --git a/packages/rest/package.json b/packages/rest/package.json index 7ffeed8d89..5ee6493b58 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 c443e6cf3e..d087a9eadd 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,761 @@ # @objectstack/runtime +## 17.0.0-rc.2 + +### Major Changes + +- 0e96e46: refactor(spec,cli,runtime)!: 退役 `crypto.hash` 能力 —— 声明了四层、构建期还自动推断,沙箱从没实现(#4391,ADR-0049 enforce-or-remove) + + `crypto.hash` 是四层声明、零层实现:`HookBodyCapability` 枚举收它、枚举旁的文档表列它、CLI 提取器**自动推断**它、`ScriptContext.crypto.hash` 还写了签名 —— 而 `installCtx` 只往 VM 的 `ctx.crypto` 上装了 `randomUUID`。于是这个 token 唯一授权的那次调用,**每一次都在 VM 里抛**。 + + 这比普通的 declared ≠ enforced 更毒一档,坏就坏在**构建期推断**:作者(尤其是 AI 作者)写下 `ctx.crypto.hash(...)`,提取器就替他把能力加进 `capabilities`,`os build` 因此全绿 —— 系统亲手把人送进一条必炸的死路,而唯一诚实的记录是文档表格里一句 `_(not yet wired)_`,没有作者会先读表格再写 body。 + + **裁决是 remove,不是实现**(维护者 2026-08-02):从未实现、调用即抛、**零投诉** —— 对一个每次使用都抛错的能力来说,这本身就是最强的活性证据,没人需要它。在沙箱里实现 crypto 会扩大沙箱的能力面与安全审查面,那是长期成本而非一次性工时,无业务拉动不做。真需要哈希时按能力准入流程重提:**实现先行,声明随实现走**(ADR-0049 的 enforce 腿留给有实现的那天)。 + + ## FROM → TO + + | 写了什么 | 现在怎么办 | + | :---------------------------------- | :------------------------------------------------------------------------------------ | + | `capabilities: ['crypto.hash']` | **删掉这个 token**。它从未授权成任何东西 | + | `await ctx.crypto.hash(algo, data)` | **删掉这次调用**。它从未返回过值 —— 今天能跑的代码没有一行依赖它 | + | 确实需要哈希 | 在 host 侧做(Connector recipe,或引擎侧 hook)。沙箱内哈希须走能力准入流程重开,实现先行 | + + 一句话修法:**两个都删**。`os migrate meta --from 16` 会自动帮你剥掉 token;那行**死调用是你自己要删的** —— 转换层刻意不改 body 源码(见下)。 + + ## 定级理由(逐条自证,未照抄前例) + + 三问按 #4535 §5 逐条走: + + 1. **会不会 TS2305 / TS2339?** 会,两处。`HookBodyCapability` 是 public 导出类型,把它当**字面量联合**用的代码(`const c: HookBodyCapability = 'crypto.hash'`、对 token 做穷举 switch)现在编译失败;`ScriptContext.crypto.hash` 的调用点以 TS2339 失败。实测三仓(objectstack / cloud / objectui)裸名扫描 `crypto.hash` / `ctx.crypto.hash` / `'crypto.hash'` —— **两个兄弟仓零命中**,本仓命中全在本 PR 内清理。 + 2. **有没有元数据迁移?** 有。token 是写在作者源 hook/action body `capabilities: []` 数组里的**值**,也会躺在已存的 `sys_metadata` 行里 —— 故注册了 ADR-0087 D2 转换 `hook-body-crypto-hash-removed`(D3 挂 protocol-17)。这是与 #4767 / #4783 / #4616 的分界:那三单退役的是**导出名 / 运行时描述符**,没有作者源可改写;本单有,和 `object-enable-trash-mru-removed` / #4734 同侧。 + 3. **形状变更?** 是**枚举值收窄**(6 → 5),不是 key 移除。故**没有 `retiredKey()` 墓碑** —— `capabilities` 这个 key 本身依然活着、依然被强制。处方改由枚举自己的 error map 承载,并按 `object.managedBy: 'system'` 的先例**以 `issue.input` 为键**:只有「曾经合法」的那个拼写会被告知「was removed」,写错成 `crypto.hsah` 的作者拿到的仍是 zod 自己那条列出合法 token 的消息 —— 告诉他「你的值被退役了」属于误导。 + + `@objectstack/cli` 与 `@objectstack/runtime` 同定 **major**:前者 `ExtractedBody.capabilities` 的公开联合类型收窄(赋值给它的代码 TS2322),后者 `ScriptContext.crypto` 少一个成员(TS2339)。 + + ## 门禁实报 + + 枚举值收窄对四张 ratchet **全部不可见**,这一点值得单独记一笔:`authorable-surface.json` 记到 key 级(`data/ScriptBody:capabilities`),`json-schema.manifest.json` 记 def 名(`data/HookBodyCapability` 仍在),`packages/spec/json-schema/` 本身 gitignore。所以 `check:authorable-surface` / `check:api-surface` 实跑**零变化**,`check:liveness` / `check:empty-state` 同样 PASS(`capabilities` key 仍活,不产生台账行变更)。 + + 也就是说:**本次移除没有任何一张基线能自动兜住它** —— 兜住它的只有本 PR 新增的 pin 测试(spec / cli / runtime 各一组,已 sabotage 实跑验证复活即红)。`check:generated` 8/8 绿,移动的是 `spec-changes.json`、`docs/protocol-upgrade-guide.md` 与两页生成参考文档(`data/hook-body.mdx`、`ui/action.mdx`,枚举选项随之少一项)。 + + ## 转换刻意不做的事 + + `hook-body-crypto-hash-removed` 只从 `body.capabilities` 里剥掉死 token,**不碰** body 源码里那行 `ctx.crypto.hash(...)`。这是有意的:那行调用从未返回过值,剥掉授权不会让任何还能跑的东西变坏;但把它一并「修好」会让作者失去唯一一个还在提醒他「这里有段死代码」的信号。`retiredFromLoadPath: true` —— 枚举当场拒绝,活作者在 parse 时就被教育,转换存在的意义是让已存的 16.x / 17-rc 行重放干净(否则永远被打成 `metadata_spec_invalid`,把链上历史误标成当期违约)以及让 `os migrate meta --from 16` 改写作者源。 + +### Minor Changes + +- 430dcc2: fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352) + + `ActionSchema.body` has always described itself as "Only used when type is + `script`", and its JSDoc went further — "Only meaningful when + `type === 'script'`. When set, the runtime invokes the body inside the sandbox + … and ignores `target`." The runtime read none of it: + `actionBodyRunnerFactory` bound a handler the moment `body` parsed, and + `collectBundleActions` collected any named action. A `type: 'url'` action + carrying a leftover `body` was therefore registered in the action registry and + executed in the sandbox — reachable through + `POST /api/v1/actions/:object/:action` and through + `ql.object(o).execute(name)`, and counted by the governance inventory as a live + handler. + + Declared ≠ enforced, in the shape that is hardest to debug: an author flips + `type` from `script` to `url`, reasonably concludes the body is now dead code, + and it keeps running with nothing anywhere saying so. + + **Behaviour change.** `body` now runs only under `type: 'script'`: + + | Action | Before | After | + | :------------------------------------------------------------- | :-------- | :----------------------------------------------------- | + | `type: 'script'` + `body` | body runs | unchanged — body runs | + | `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) | + | `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged | + + Only an action that **explicitly** declares a non-`script` type _and_ carries a + `body` changes behaviour. An omitted `type` still means `script`, because the + collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy + `manifest.actions[]` never passes through `ActionSchema`, so the schema's own + default has to be applied at the gate rather than assumed to have been applied + already. + + **FROM → TO.** If you have an action whose body you want to keep running, set + `type: 'script'` and move the navigation/dispatch target elsewhere; if you want + the target behaviour, delete the now-inert `body`: + + ```diff + { + name: 'open_portal', + - type: 'url', + + type: 'script', + target: '/portal', + body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] }, + } + ``` + + The refusal is **not** silent — silence would only relocate the invisibility the + issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its + declared `type`, and both fixes. + + Authoring-time rejection of the same contradiction already shipped in #4438 + (`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains + reachable here is data at rest published before that gate existed, plus bundles + that never parsed. This release closes that half. New tests also pin that the + **publish gate resolves to the rejecting schema** — through + `getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of + either registration cannot silently reopen the hole while the schema's own unit + tests stay green. + + `@objectstack/lint`'s `validate-action-body-writes` filters by `type` again. + #4344 deliberately made that rule type-blind on the grounds that "the runtime + binds a handler from `action.body` alone … checking what executes beats checking + what the schema says should" — true then, and the comment predicted its own + revision. Execution and declaration are the same set again, so a non-`script` + body no longer produces write-set advice about writes that provably never + happen; the publish gate names that metadata's real defect (`type`) with its own + prescription. + + `collectBundleActions` stays deliberately type-blind: it feeds governance + surfaces that must enumerate every declared action, bound or not, and the other + bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never + walks it. The gate lives at the single point where a `body` becomes an + executable handler, so there is no second copy of the rule to drift. + +- 63b33e6: A `datasourceMapping` rule is routing, not a hint — an object mapped to an + unreachable datasource no longer silently reads and writes the DEFAULT store + (#4462). + + **Observable behavior change; read this before upgrading.** Measured on `main` + during the v17 verification: map an object to a Postgres datasource with a bad + URL and the boot succeeds, `/ready` answers `200`, the datasource name appears in + **zero** log lines, `POST /api/v1/data/` returns `201` — and the + row is physically in the default store. The operator finds out by opening the + database they declared and finding it empty. ADR-0062 D2's phase-1 note called a + mapping-only datasource "decorative" to keep an example byte-for-byte unchanged; + what that bought was a silent data-placement bug. + + The fix is a pair, and each half is what makes the other correct: + + 1. **Routing stops falling through** (`@objectstack/objectql`). `getDriver` step + 2: a mapping rule that MATCHES and names a datasource with no live driver now + throws — `DatasourceUnavailableError` when the connect layer recorded a + verdict, otherwise an error naming the object, the datasource and the two + remedies. `default` still resolves onward: the default driver keeps its + natural name (#3826), so step 5 is how routing to it works. + 2. **ADR-0062 D2 grows gate (d)** (`@objectstack/service-datasource`, + `@objectstack/runtime`). A datasource a mapping rule routes at least one + object to is auto-connected at boot, and a boot-time connect failure is + **fatal** with an operator-readable reason — the same call gate (b) already + makes for an explicit `object.datasource` binding, now correct for (d) + because half 1 removed the fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still + degrades the boot instead, as for every other fatal connect. + + The mapped-object list is resolved by the boot path from the engine's own + matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) and passed to + `connectDeclared({ mappedObjects })`; the connection service never re-derives + rule matching. Two matchers drifting by one clause would connect a datasource + routing never uses, or route to one nothing connects — the defect again. + + **What to do if this breaks your boot.** It means a `datasourceMapping` rule in + your stack points at a datasource that cannot be connected. Either fix the + datasource configuration, or delete the rule — the second is what + `examples/app-crm` did in this change, and it is what keeps that example's + runtime behavior identical: its rules routed everything to an unconnected + `:memory:` datasource, i.e. to the default store by fall-through. + +- ac471a0: **BREAKING**: `IAutomationService.getSuspendedScreen(runId)` is now **async** — it returns `Promise` instead of `ScreenSpec | null` (#4515). + + FROM → TO for anyone calling or implementing it: + + ```ts + // caller + - const screen = automationService.getSuspendedScreen(runId); + + const screen = await automationService.getSuspendedScreen(runId); + + // implementer + - getSuspendedScreen(runId: string): ScreenSpec | null + + async getSuspendedScreen(runId: string): Promise + ``` + + One-line fix: `await` the call (the enclosing function is almost certainly already `async`), and make any test double resolve rather than return (`mockResolvedValue`, not `mockReturnValue`). + + Why it had to change: the method could only ever read the engine's in-memory hot cache, because a synchronous signature cannot consult the durable suspended-run store. `SuspendedRun.screen` _is_ persisted (`sys_automation_run.screen_json`) and `resume()` cold-reads it back, so after a process restart a still-suspended screen run could be resumed (`POST …/runs/:runId/resume` → 200) while `GET …/runs/:runId/screen` returned 404 “No pending screen for run” — the refresh-safe re-fetch failing in exactly the situation it exists for (page refresh, another device), and the rendering half of ADR-0019's durable-suspend promise missing while the resuming half shipped. + + `AutomationEngine.getSuspendedScreen` now takes the hot cache as its fast path and falls through to the store via the same loader `resume()` rehydrates from. A run that does not exist, is no longer suspended, or paused at a non-screen node still resolves to `null`, so `GET …/runs/:runId/screen` keeps returning 404 for genuinely absent runs. No sync variant of the method remains on the contract. + +- eb4204b: feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + + The `script` executor's contract — _the named function returns a value; data I/O + stays on the flow graph_ — existed only as a comment inside the executor, while + #4354's run summary depended on it. That summary reports no record metrics for a + `script` step precisely because a pure function's writes are downstream + `create_record` / `update_record` nodes counting themselves. A function that + wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable + from the broken sweep the counters exist to detect, recorded permanently on + `sys_automation_run`. + + **The rule is now visible.** `ActionDescriptor` carries + `handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes + `'pure'`, so the action catalog, the designer palette and the reference docs + state the rule an author has to follow instead of an executor holding it + privately. + + **And a legitimate writer can opt out honestly.** A `defineStack({ functions })` + entry may declare what it does, in either shape: + + ```ts + defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: "writes" }, // declared writer + }, + }); + ``` + + A step calling a declared writer reports `unmeasuredEffect`, so the run's + `unmeasured` tally keeps the broken-sweep query + (`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that + flow. Marking _every_ `script` step unmeasured was rejected: it would blind the + detector on every flow that calls any function in order to cover the few that + break the rule. + + Nothing here is retired or renamed: a bare `functions: { fn }` entry is + unchanged and means `effect: 'pure'`. The declaration is carried end to end — + `ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the + existing `packageId` string and exposes `resolveFunctionEntry(name)`, + `objectstack build` lowers a declared entry without dropping it, and the + artifact loader re-attaches the module's callable to the declaration the JSON + carried. + + **Also fixed:** `bindHooksToEngine` returned before registering a bundle's + functions when the stack declared no hooks, so a flow-only app's + `defineStack({ functions })` reached the engine as nothing and every `script` + node calling one failed with "no function named 'x' is registered". + +- 8aacf94: feat(rest,runtime,client): `POST /meta/_migrate-stored` — run the stored-metadata migration without a shell (#4327) + + `os migrate meta --stored` (#4327) gave ADR-0087's stored-metadata chain a finish + line, but only for someone who can reach the deployment's database from a + terminal. A hosted operator cannot, so on a managed deployment the chain had no + finish line at all — just the per-read conversion, running forever, with no way + to assert what protocol the rows are on. + + The same pass is now reachable over HTTP: + + ```ts + const preview = await client.meta.migrateStored(); // writes nothing + const result = await client.meta.migrateStored({ apply: true }); + const flows = await client.meta.migrateStored({ types: ["flow"] }); + ``` + + It returns the same `StoredMigrationReport` the CLI renders, and takes the same + posture: + + - **Preview by default.** `apply` must be literally `true`; an empty body, a + missing body, and `"apply": "yes"` all preview. Nothing is inferred. + - **Gated on `manage_metadata`.** Unlike the single-item `PUT /meta/:type/:name` + next door, this rewrites every eligible row in the deployment, so it demands + the ADR-0066 D1 authoring capability rather than just a session, and answers + `403` otherwise. The gate runs before the protocol is probed, so an + unauthorized caller cannot use `403`-vs-`501` to learn which kernels can be + migrated. `/meta`'s anonymous-deny umbrella still closes it to anonymous + callers first. + - **Attributed to the caller.** The `actor` recorded on the history and audit + rows names the user who fired it — that is the question those rows exist to + answer. + + **Flows need no extra setup on this path.** The CLI has to boot an inert + automation engine to hold the executor registry ADR-0078's conflict guard needs; + a server already has a live one, and the protocol resolves it from the services + registry itself (#4498), so this route covers flow rows by simply running in the + process that owns them. + + Registered on both the REST server and the runtime dispatcher's `/meta` domain, + ledgered in both route ledgers, and mounted before `/:type` so the + leading-underscore segment is never captured as a metadata type name. + +- 071d0dc: feat(runtime,cli,core): boot reconciliation and `os migrate resume` for the migration journal — an interrupted run can no longer go unnoticed (ADR-0119 D2, #4617) + + Completes ADR-0119 D2. The runner and `sys_migration_journal` landed in #4668; this is the discovery channel that makes an interrupted run findable by someone who does not already know it happened. + + **`MigrationRecoveryPlugin` (`@objectstack/runtime`)** — at `kernel:ready`, scans the journal for runs that started and never concluded, and warns per run: how many chunks committed, which have an **unknown** outcome (`chunk_started` with no `chunk_done`), whether a compensation was left half-finished, and the exact command that will act. It also owns the `migration-plans` registry service. + + **`os migrate resume` (`@objectstack/cli`)** — lists interrupted runs (read-only, the default), or acts on one with `--run `, under confirmation. Exits non-zero when a run ends `failed`, so a scripted recovery cannot move on from a migration that needs a human. + + **`MigrationPlanRegistry` (`@objectstack/core`)** — where a resume finds the plan it has to re-run. + + ## Boot discovers, the CLI acts + + This is the design decision, and it is deliberate rather than incidental. + + Resuming is a large, irreversible, potentially hour-long write against production data. Doing that as an unrequested side effect of a process starting is the kind of behaviour an operator finds out about from a graph. It is also not always possible at boot: a resume needs the plan's live callbacks, and the package that owns them may not be loaded in whichever process happened to restart first. + + So boot surfaces the run and names the command; the command acts, under explicit operator intent. ADR-0119 D2's per-plan `onCrash` policy still decides **what** acting means — resume forward from the first chunk lacking `chunk_done`, or unwind what committed — it just does not decide **when**, and "when" is the part a human should own. + + Deferring is safe precisely because of the runner's re-entrancy: `started ∧ ¬done` is durable, so an interrupted run stays exactly as recoverable an hour later as it was at boot. Nothing decays while the operator decides. + + ## Why a plan registry exists at all + + A journal cannot hold a plan. `forward` and `compensate` are functions and `load()` reads the live database, so none of it crosses a process boundary — which is why the journal records the plan **hash**, not the plan. Recovery therefore needs the plan handed back by the code that owns it, and `migration-plans` is that seam: between "the journal knows a run stopped at chunk 7" and "something in this process knows what chunk 7 was supposed to do". + + A run whose plan no loaded package registers is **reported**, never silently skipped — the operator is told which plan id is missing. "Nothing to resume" and "the code that owns this run is not here" are different facts, and only one of them is safe to ignore. + + ## Degradation + + No engine, or no `sys_migration_journal` registered (a lean kernel that never composed platform-objects) → the scan is skipped in **silence**: such a kernel has no interrupted runs to find, and a warning there would train operators to ignore this plugin's output, which is the one thing it cannot afford. A scan that **fails**, by contrast, is reported — "I could not check" and "there is nothing to find" are different answers. + + 11 new tests pin the split (boot writes nothing to the journal), the three states an operator must tell apart (clean / interrupted / half-unwound), and both degradation paths. + +- ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + + Four independent surfaces where the answer a caller received contradicted the + contract the surface declares. All four were found driving a real showcase boot + against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + + - **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + + - **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + + - **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + + - **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + + These change responses that were observably wrong, so callers coded against the + buggy shapes — a 200 on a missing record, a 400 on a capability denial — will + see different status codes. Graded `minor` on that basis rather than `patch`. + +- dadb43f: refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451) + + The `workflow` slot was ADR-0078's silently-inert declaration at every layer at + once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115 + Evidence 5 — "no code in this repository resolves either slot", verified across + both repositories), an `IWorkflowService` contract with zero implementations, a + `WorkflowProtocol` whose three methods no code ever provided, a discovery + `routes.workflow` field no builder could truthfully populate, and a + `/api/v1/workflow` advertisement for a path no host ever mounted (the + pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that + never existed). The capability it promised is live elsewhere and has been for + majors: record state machines are enforced by the `state_machine` validation + rule, approvals are first-class flow nodes on the approvals runtime + (ADR-0019), and record-triggered automation is lifecycle hooks + + `record_change` flows (`service-automation`). + + FROM → TO: + + - `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` / + `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill. + - `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no + implementation ever existed. Register nothing — use the mechanisms above. + - `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`, + `WorkflowState`, `GetWorkflowStateRequest/Response`, + `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed, + along with the seven published JSON schemas. Delete the import; nothing + ever answered these shapes. + - Discovery `routes.workflow` / `services.workflow` / `features.workflow` + (metadata-protocol + runtime builders) → absent. A reader keying on them + only ever saw `unavailable` / `false`; delete the read. + - `RouterConfig.mounts.workflow` → removed; there was never a surface to + mount at it. + - `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent + routes as `'automation'`. + - `@objectstack/client` re-exports of the four workflow types → removed with + their source. (The `client.workflow.*` methods were already removed earlier + in the v17 cycle — this retires the types they returned.) + - Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the + `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a + `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out + of the product plan (#2462 follow-on). + + The retirement kit: the `workflow-service-slot-retired` semantic migration + (major 17) carries this prescription into `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a + discovery response field — never stored in stack metadata — so there is no + load-path conversion and nothing for `os migrate meta` to rewrite; the + 21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` + entries for the deleted schemas are dropped deliberately in the same change + (the plugin-runtime precedent: a prescription nobody can receive is noise — + nothing parses these shapes any more). + +### Patch Changes + +- 7e7a605: fix(runtime): carry the capability channel onto an AI route's `req.user` (#4705) + + `/ai/*` was the one route domain in the platform where a capability check could + not be written. The dispatcher builds `req.user` from the request's + ExecutionContext, and `resolveAuthzContext` resolves a caller into **two** lists + that look alike and are not: + + | ExecutionContext field | Carries | + | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | + | `permissions` | permission-**set names** (`admin_full_access`, `organization_admin`, `member_default`) plus the synthesized `ai_seat` | + | `systemPermissions` | **capabilities** — `manage_metadata`, `studio.access`, `setup.access`, … — the union of every resolved set's `systemPermissions[]` | + + Only the first was copied. Every other surface gates on the second + (`domains/meta.ts`'s `manage_metadata` check, `action-execution.ts`, + `rest-server.ts`), so the same test written against an AI route's + `req.user.permissions` was **permanently false** — a gate built on it would not + have tightened the route, it would have closed it on platform admins too. That + is what blocked the capability gate on + `POST /api/v1/ai/tools/:toolName/execute`, where any authenticated user can + currently run any registered tool (`create_object`, `apply_blueprint`, + `create_seed`) in the default configuration. + + `req.user` now carries `systemPermissions` alongside `permissions`, with the + same fail-closed default the neighbouring fields use: a non-array — or an + ExecutionContext that has none, since the field is optional — becomes `[]`, + never `undefined`. The two channels are copied **side by side and never merged**: + flattening either into the other would corrupt every existing reader of + `permissions` while appearing to fix this. + + This is transport only. No route in this package gates on the new field, and the + declared-but-unenforced `route.permissions` mechanism is untouched — consumers + decide policy, on the platform's existing `systemPermissions` contract. + + The other producer of an AI-route `req.user` — `dispatcher-plugin`'s + `resolveRequestUser`, backing the concrete per-route mounts — has no + ExecutionContext to read and stays capability-less on purpose. It now says so in + the same shape (`systemPermissions: []`, spelled out rather than omitted) so a + consumer never sees `undefined` on one path and `[]` on the other, and so needs + no fallback of its own to tell them apart. + +- 2826d1e: fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + + A flow paused at an `approval` node, a deploy, then an approver clicking + Approve: the request row flipped to `approved`, the UI toasted success — and + the flow never moved. No next-stage request, no error, the record's mirrored + status frozen mid-workflow. Approval flows pause for days by design, so a + restart mid-flight is the normal case: every release could quietly zombify + every in-flight approval, with the approvers none the wiser. + + Durable suspended runs (#1518) had shipped and were not the missing piece. Two + other things were. + + **The wiring could enable a store over a table nobody had created.** Object + registration and store activation resolve different services in different + phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin + declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, + warned, and continued; `start()` then attached the DB-backed store anyway. Every + suspend failed with `no such table: sys_automation_run` into a log line nobody + read, pauses silently stayed in memory, and the next restart lost them all. + Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an + engine-less kernel must still boot); a registration missed at `init()` is + retried at `start()`, which still lands before ObjectQL's schema sync; the + store is never attached when registration did not happen, and says so at + **error** level instead of warning; the table is probed once at boot so a + broken setup surfaces there rather than one failed write at a time; and a + failed durable write of a paused run is logged at error — it is data loss in + waiting, not a warning. + + **A reported resume failure read as success.** `AutomationEngine.resume()` + answers a lost run by _returning_ `{ success: false }`, never by throwing. + `ApprovalService` discarded that return value, and `decide()` counted only a + thrown error as failure — so a decision against a dead run came back + `resumed: true`, HTTP 200. Resume failures are now classified + (`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining + `PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is + distinguishable from a store that is merely unreachable, and the raw resume + route maps them to 404 / 503 / 409. + + Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which + reads the suspension store, unlike `getRun()`, and throws rather than answering + `false` when the store is unreadable — pre-flights every flow-advancing + operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the + zombie half-state is never created rather than merely reported: the decision + fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A + resume that fails after the decision is durable can no longer be undone, but it + now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of + reporting success. A concurrent duplicate resume stays benign — the engine's + idempotency guard is doing its job — and reports through the new optional + `resumeError` field. Recall and revise-window cancellation stay non-fatal by + design (they abandon the request), but log at error with the reason instead of + swallowing it. Compositions with no automation engine attached are unaffected. + + Existing zombie requests from affected deployments (already `approved`, run + stranded) are not repaired by this change — `releaseDeadRunRequests` only + sweeps requests that are still `pending`. + +- ff17642: fix(runtime): declarative `defineJob` cron jobs are actually scheduled (#4567) + + Every background job authored as `defineJob({ schedule: { type: 'cron', … } })` + was **silently never scheduled**. `JobSchema.parse` rewrites the cron + `expression` into the canonical expression envelope + (`{ dialect: 'cron', source: '0 1 * * *' }` — the authoring/persistence tier), + but `AppPlugin` handed `job.schedule` verbatim to `IJobService.schedule`, whose + boundary contract documents `expression` as a **bare cron string** because + `CronJobAdapter` passes it straight to croner. croner rejected the object + (`CronPattern: Pattern has to be of type string.`), the throw was swallowed by a + per-job `try/catch` that only `warn`ed, and the author saw a green build and a + green boot with the job never running. `interval` / `once` schedules and + flow `schedule` triggers were unaffected. + + **Fix (contract-first).** The authoring→boundary downgrade now happens at the one + place the two tiers meet — `AppPlugin`'s declarative-job registration, alongside + the existing `retryPolicy` / `timeout` threading — via + `toBoundaryJobSchedule()`. The adapters stay strict: no `typeof === 'object'` + tolerance was added downstream, so the boundary keeps exactly one shape. + A schedule that cannot be reduced to it (unknown type, AST-only or non-`cron` + expression envelope, missing `intervalMs` / `at`) is rejected by name. + + **The failure path is no longer silent.** A job that cannot be scheduled now logs + at **error** level with its own message (`Background job FAILED TO SCHEDULE — it +will never run`), plus a boot summary line when any job failed, and increments + the new `job_schedule_failures_total` counter + (`SEMCONV.jobScheduleFailuresTotal`, labels `app` / `job`) on the observability + metrics registry. "Failed to schedule" no longer shares the quiet `warn` used by + "handler not found in bundle.functions" — the first is an outage of declared + work, the second is a job that was never going to run. + + No authoring change is required: existing `defineJob` cron declarations start + working on upgrade. + +- 20bc357: fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318) + + The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue` + and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no + dispatcher domain, no adapter mount, no plugin registration, and the shipped + providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will + never mount one. Every default boot therefore advertised a route inside the same + `ServiceInfo` whose `handlerReady: false` said the opposite — a single record + contradicting itself (ADR-0076 D12). + + These slots are route-less now, like `realtime` — but unlike `realtime` an + unmarked real implementation stays `available`: the slot's contract is + in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady` + is reported `false` on both discovery builders — for a route-less slot it is not + a proxy for anything, it is the fact itself (the dispatcher used to claim + `handlerReady: true` here for an unmarked occupant, a handler that does not + exist). The explanatory message is written once, as + `inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two + builders cannot drift apart. + +- 5a84d41: fix(automation): `resume` enforces the suspended screen's declared field contract (#4477) + + A `screen` node's `config.fields` is a complete input contract — the author + declares the keys, their `required`-ness, and (via `visibleWhen`) when a field + is even asked for. The RENDER half honoured all of it: the paused result and + `GET …/runs/:runId/screen` carry `required` and `visibleWhen` intact. There was + no VALIDATION half — `POST …/runs/:runId/resume` folded whatever bag it was + handed straight into the flow variables, so a caller that skipped the dialog and + posted here directly was unconstrained by every `required` the author wrote. + Missing required fields, and keys the screen never declared, all completed the + run with `success: true`. + + Screen flows are the one place where the declared field contract is the ONLY + contract — no object schema sits behind a screen node to catch a bad bag + downstream. The platform already enforces the analogous contract everywhere else + this seam appears: action params (ADR-0104 D2), record writes (ADR-0113), + approval `decisionOutputs` (#3447). This is that rule for screen resume, built in + the same shape. + + `resume` now refuses a non-conforming submission with the new + `AutomationResult.code` `'INVALID_SCREEN_INPUT'` (a transport maps it to **400**, + as the automation domain route now does) and an `Invalid screen input: …` message + that names each violation and lists the declared field names. The refusal happens + BEFORE the suspension is consumed, so the pause stays live and the legitimate + submission still lands. + + `visibleWhen` is evaluated against the SUBMITTED values first (layered over the + run's variable snapshot), so a hidden field's `required` never fires — enforcing + it would dead-end the run at a field the user was never shown, which is #3528 + reproduced server-side. A predicate that cannot be evaluated is logged and + treated as hidden rather than visible: the client decides what the user saw, and + a broken predicate is not evidence a field was on screen. + + Scope, deliberately narrow — three shapes keep the historical pass-through: + + - an **object-form** screen (`kind: 'object-form'`), whose `fields` is empty by + construction because the client renders the object's own form and the write + path enforces that object's `required` fields itself; + - a **message-only** screen (`waitForInput: true`, no fields), which declares no + keys and so constrains none — the same pass-through `enforceActionParams` + gives a param-less action; + - `signal.output`, the node-OUTPUT namespace, which belongs to the approval-style + resume envelope rather than to the screen's collected-values channel. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [0800433] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [328ccc5] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2eb850] +- Updated dependencies [8bd437f] +- Updated dependencies [5046afe] +- Updated dependencies [203a449] +- Updated dependencies [6dcbbc3] +- Updated dependencies [ac37fc6] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [58434f5] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [c4ab50b] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [8aacf94] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [05d8a54] +- Updated dependencies [9b43ee2] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [4c80fd6] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [63b33e6] +- Updated dependencies [8aacf94] +- Updated dependencies [6beb708] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [83cf2d3] +- Updated dependencies [071d0dc] +- Updated dependencies [beefe89] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [69b509f] +- Updated dependencies [7e05d8e] +- Updated dependencies [0d9a779] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [4b945fc] +- Updated dependencies [1ee48bc] +- Updated dependencies [705e5c8] +- Updated dependencies [f61edce] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [0657f6b] +- Updated dependencies [666f542] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [304423e] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [be90dea] +- Updated dependencies [04f1182] +- Updated dependencies [c03108c] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [24915d2] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/metadata-protocol@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/plugin-auth@17.0.0-rc.2 + - @objectstack/plugin-security@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/rest@17.0.0-rc.2 + - @objectstack/driver-sql@17.0.0-rc.2 + - @objectstack/driver-memory@17.0.0-rc.2 + - @objectstack/metadata@17.0.0-rc.2 + - @objectstack/service-datasource@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/driver-sqlite-wasm@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + - @objectstack/service-cluster@17.0.0-rc.2 + - @objectstack/service-i18n@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ca97baf251..d28e7670f8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 bce8344d80..6426e80c00 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index cd2fbee84f..1214cd1e85 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.1", + "version": "17.0.0-rc.2", "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 108edfecba..f80c2c0eba 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,439 @@ # Changelog — @objectstack/service-analytics +## 17.0.0-rc.2 + +### Major Changes + +- 3c7bcc0: feat(spec)!: converge the 11 contracts-vs-domain dual-source type names (#4538) + + `packages/spec/src/contracts/` hand-wrote parameter/result interfaces whose + names collided with same-named zod-derived types in the domains — the #4411 + trap, tracked as 11 rows of `dual-source-exports.baseline.json`. Each name was + judged individually against a three-repo import-level scan (framework, cloud, + objectui): which declaration actually flows at runtime decides the direction. + All 11 rows are deleted from the baseline; no name below is exported twice + anymore. + + **Converged — `./contracts` now re-exports the domain zod type (same + declaration on both entries, imports keep compiling from either):** + + - `NotificationChannel` → `system/notification.zod`'s + `z.infer` (member sets were identical). + - `ValidationResult` → `kernel/plugin-validator.zod` (shapes were identical). + - `HealthStatus` → `kernel/startup-orchestrator.zod` (`details` narrows + `Record` → `Record`). + - `PluginStartupResult` → `kernel/startup-orchestrator.zod`. FROM `plugin: +Plugin` (live object) and `error?: Error` TO the serializable projection + (`plugin: { name, version? }`-passthrough, `error?: { name, message, +stack?, code? }`). Neither side had any consumer outside spec; the + zod-validatable shape wins. + - `StartupOptions` → `kernel/startup-orchestrator.zod` — the PARSED tier + (defaults applied). `IStartupOrchestrator.orchestrateStartup` now takes + `StartupOptionsInput` (the caller-authored all-optional tier, also + re-exported from `./contracts`). Fix for callers typed to the old + all-optional `StartupOptions`: rename to `StartupOptionsInput`. + - `JobExecution` → `system/job.zod`. The system schema's `duration` field is + RENAMED `durationMs` — that is what every job adapter produces and what the + `sys_job_run.duration_ms` column round-trips; the schema described records + nothing ever wrote. Fix: `duration` → `durationMs` when parsing + `JobExecutionSchema` payloads. + - `AnalyticsQuery` → `data/analytics.zod`. The domain schema aligned to the + contract's semantics first: `timezone` LOST its `.default('UTC')` — absence + is meaningful (the engine resolves org timezone, #1982/#2018; the + `/analytics` entry always refused to apply that default). The schema is now + transform-free, so `AnalyticsQuery` ≡ `AnalyticsQueryInput` (both kept + exported). Fix for code that relied on `.parse()` injecting `timezone: +'UTC'`: pass the timezone explicitly or resolve it via the engine chain + (`selection.timezone ?? context.timezone ?? 'UTC'`). + + **Renamed — two genuinely different concepts were sharing one name (both + flow at runtime):** + + - `./contracts` `DriverCapabilities` → **`AnalyticsDriverCapabilities`** + (`{ nativeSql, objectqlAggregate, inMemory }`, the analytics strategy-chain + execution-path probe). The `DriverCapabilities` name now belongs solely to + the data domain's driver feature-flag record (`DriverCapabilitiesSchema`, + what `IDataDriver.supports` declares). Fix: importers of the trio from + `@objectstack/spec/contracts` (or `@objectstack/service-analytics`, whose + re-export is renamed in lockstep) rename the import; importers who meant + the driver flags import `DriverCapabilities` from `@objectstack/spec/data`. + + **Removed — the domain-side declaration was dead (zero import-level consumers + in framework/cloud/objectui; the #4411 family's last survivors):** + + - `system` `MetadataExportOptionsSchema` / `MetadataExportOptions` and + `MetadataImportOptionsSchema` / `MetadataImportOptions` (the + `output`/`source`-directory bags). The names now have ONE declaration each: + the `IMetadataService.exportMetadata` / `importMetadata` parameter + interfaces on `./contracts` (`types`/`namespaces`/`format` and + `conflictResolution`/`validate`/`dryRun`), which `MetadataManager` + implements. No tombstone/D2 conversion, deliberately — these are runtime + option-bag types, not authorable metadata (same reasoning as #4458). + `@objectstack/metadata` re-exports the two names from `./contracts` now + (it previously re-exported the dead system-side shapes its own manager + did not accept). + - `system` `JobSchedule` (the `= Schedule` back-compat alias). The name's one + declaration is the `IJobService.schedule` boundary shape on `./contracts` + (plain-string cron `expression`); the authored metadata type keeps its real + name `Schedule`. Fix: `import type { JobSchedule } from +'@objectstack/spec/system'` → `Schedule` (authoring tier) or the + `./contracts` `JobSchedule` (service boundary), whichever you meant. + +### Minor Changes + +- fa94b2c: fix(service-analytics): a measure a query never reported reads 0 for a count/sum on every merge seam (#4708) + + A dataset measure carrying its own `filter` runs as a separate grouped + sub-query and is merged back onto the selected dimensions. A `GROUP BY` over a + filtered row set emits **no group at all** for a dimension value the filter + excludes entirely, so the measure comes back **absent**, not `0` — and + `computeDerived` treats an absent operand as unknowable, so every ratio over it + goes null too. The cell then renders blank, which is visually identical to "no + data for this row" and means the opposite. + + The bias runs the worst possible way: the rows that blank are the ones whose + numerator matched nothing — the **worst-performing rows**. A `lead_source` that + won nothing rendered as "no data" while one that won everything rendered fine. + + The empty-group value is now filled **by aggregate kind** into every measure + column the assembled grid lists but no query reported: + + | aggregate | over an excluded group | why | + | :------------------------ | :--------------------- | :------------------------------------------------------------------ | + | `count`, `count_distinct` | `0` | "how many rows matched" has an exact answer when the answer is none | + | `sum` | `0` | the identity element of the empty set | + | `avg`, `min`, `max` | stays `null` | genuinely undefined — there is nothing to average | + + Filling all five with `0` would trade this lie for its mirror image, reporting a + measurement nobody made, so the kinds are judged separately (via + `emptyGroupValueFor`, shared with the authoring-side coherence checks). + + **Only cells are filled, never rows.** A dimension value no query reported at + all has genuinely no data and stays out of the grid. + + **What changes beyond the measure-scoped seam.** The fill previously ran before + the `compareTo` merge, and that merge _appends_ a row for every bucket the + PREVIOUS window had and this one does not. Every base measure on those rows — + including unfiltered ones — was absent, so a lead source that sold last month + and nothing this month rendered as "no data" instead of `0`: the same worst-row + bias, one merge later. The fill now runs after every merge and covers all base + measures plus their `__compare` columns. + + Widgets that worked around this with `?? 0` in the consumer or a `coalesce` in + the measure can drop it; the coercion belongs in the executor, which is the only + layer that knows which aggregate produced the gap. + + **New export.** `fillEmptyGroups(rows, columnAggregates)` is exported from the + package root beside `mergeByDimensions`, so a host assembling a grid outside + `DatasetExecutor` can apply the same aggregate-kind rule rather than + reimplementing it — which is what makes this a `minor` rather than a `patch`. + +- 328ccc5: fix(security,analytics): scope /analytics/query to the caller's readable records, and refuse a measure over a missing field (#4467, #4437) + + Two defects on the analytics query path, both found by the v17 verification run + (#3909 / #4482), both reproduced against a live showcase server before the fix + and re-verified with the same requests after. + + ## #4467 — `/analytics/query` applied no record-level scoping + + `ISecurityService.getReadFilter` documents itself as "the same filter the engine + middleware AND-s into every find", and exists precisely for paths that bypass + that middleware — its own doc comment names the analytics raw-SQL path. But the + chain it mirrors is TWO sibling middlewares: plugin-security's RLS injection and + plugin-sharing's owner/share visibility filter (`buildSharingMiddleware` AND-s + `buildReadFilter` into `ast.where` for `find`/`findOne`/`count`/`aggregate`). + Only the RLS half was ever computed here, and analytics has no other source of + scope, so the OWD/share predicate simply never existed on that path. + + Live repro: `showcase_private_note` is `sharingModel: 'private'`; an admin owns + 5 notes, a member holds read shares on exactly 2 and no `viewAllRecords`. + `GET /data/showcase_private_note` correctly returned 2 for the member, while + `POST /analytics/query {measures:['count']}` returned 5 — and adding + `dimensions:['title']` returned all five titles, i.e. the VALUES of a column + that caller may not read, not merely a bad count. Any authenticated caller who + could reach `/analytics` could enumerate the field values of every row of any + object exposed as a cube, regardless of OWD, sharing rules, or RLS. + + `getReadFilter` now resolves plugin-sharing's `buildReadFilter` through the + late-bound `sharing` service and AND-composes it with the RLS filter — the same + composition the two middlewares reach by both writing into `ast.where`. It also + computes the ADR-0057 D1 `__readScope` depth that the security middleware + normally stashes on the context for plugin-sharing to widen its owner-match + with, using the same `getEffectiveScope` call the middleware makes: no + middleware runs on this path, and without it a caller granted `unit`/`org` read + depth would be silently narrowed to `own`. The sharing predicate is resolved for + every non-system caller AHEAD of the RLS stand-down branches, because those are + the RLS middleware's own early exits and none of them is a reason to drop a + sibling middleware's predicate; a sharing-resolution failure denies outright + rather than falling through to half a scope. + + **Why `minor` rather than `patch`.** This is an observable behaviour change on a + public read surface, in the narrowing direction: analytics results that a + principal could previously read they now cannot. Counts drop, `dimensions` + groupings lose rows, and any dashboard, report, or export built on + `/analytics/query` over an owner-private object will show smaller numbers for + non-superuser principals — correctly, but visibly. Deployments that had (however + unknowingly) come to depend on the unscoped totals will see them change on + upgrade, so this warrants more than a patch-level note even though it is a + security fix. No API signature changed: `ISecurityService.getReadFilter`'s + declaration is untouched — the implementation merely started honouring the + contract it already documented. + + ## #4437 — a measure naming a missing field 500'd with SQLITE_ERROR + + `inferMeasure('ghost_sum')` maps a suffix convention onto a field name and has + no way to know the field exists, so it built `SUM(ghost)`, the driver threw + `no such column`, and the caller got + `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error + class as the `error.code` for what is a plain typo, which ADR-0112 forbids. A + dotted spelling took the same path (`measures:['total.sum']` prefix-strips to + `sum` → `SUM(sum)` → 500). The DATA route has refused the identical mistake with + a `400 INVALID_FIELD` naming the field since #4315/#4254. + + `AnalyticsService.ensureCube` now validates each measure's resolved source field + against the backing object's field names before any SQL is built, and rejects + with the same envelope the data route produces (`400 INVALID_FIELD` carrying + `field`, `object`, `param`, `measure`) so one mistake has one shape across + `/data` and `/analytics`. The new `getObjectFieldNames` config hook reads the + same schema registry `isRegisteredObject` already consults and the data path's + own gate reads, so "which fields exist" has a single answer across both routes. + + The gate is tiered exactly like the #3867 cube-inference gate, deliberately + narrow: it applies only when the cube's `sql` is a bare object name (an authored + cube whose `sql` is a real SQL expression has no field list to check against), + only when the probe answers (no data engine, or an external datasource whose + columns are not mirrored locally, stands down), and only to measures whose + source is a bare column — `count(*)` has no source field, and a dotted + cross-object reference resolves through a join this layer cannot see, so both + pass through untouched. `id`/`created_at`/`updated_at` are admitted + unconditionally, matching the data path's `resolveQueryFields`: a gate stricter + than the engine it guards would reject queries that used to work. Validation + runs before the cube is registered, so a rejected query leaves no trace in the + registry — otherwise a retry would find a "registered" cube carrying the bogus + measure and sail straight into SQL. + + This half is `minor` for the same envelope reason: a request that used to return + 500 now returns 400 with a different `code`, which is a visible contract change + for any caller branching on the response. + +- 6117f7b: fix(spec,service-analytics): a percentage measure carries its SCALE, so a ratio of 1 is 100% (objectui#3136) + + A `%` format string says how to PRINT a number, not what scale that number is + on — and the two readings collide at exactly `1`, which is both "100%" (a 0–1 + ratio at full compliance) and "1%" (a single percentage point). With nothing on + the wire to tell them apart, renderers guessed from the value's magnitude and + resolved the collision the wrong way: an SLA / pass-rate dashboard reporting + `sla_rate = 1` displayed **"1.0%"** — "everything met the SLA" read as "1% met + the SLA" — on both the KPI card and the dataset table. + + The scale was never actually unknowable; it just never left the server. A + measure declaring `derived: { op: 'ratio' }` is a 0–1 fraction _by definition_, + and a measure aggregating a `percent` field has whatever scale that field + stores. Both facts sit in metadata the enrichment pass already reads for the + ADR-0053 currency chain — which walks back to the source field, checks + `type === 'currency'`, and rides the resolved code onto the result column. + Percentages got no such treatment. They do now, through the same seam. + + **`percentScaleOf(field)` (`@objectstack/spec/data`)** is the one place the + question is answered. A `percent` field stores a FRACTION unless it declares + `max > 1` (e.g. `min: 0, max: 100`), which marks whole-percent storage — the + same rule the percent edit widget already writes by, so a value round-trips. + Non-`percent` fields get no opinion: a plain `number` an author formatted with + a `%` keeps meaning exactly what their format string says. + + **`AnalyticsResult.fields[].percentScale`** carries the answer: `'fraction'` + (`1` ⇒ "100%") or `'whole'` (`1` ⇒ "1%"), absent when the column is not a + percentage. `queryDataset` sets it from the measure's `derived.op === 'ratio'` + first, then the source field's scale. `currency` — emitted since ADR-0053 but + only ever written through a cast — is now declared on the same interface. + + The config seam `measureCurrency` is renamed **`sourceFieldMeta`** and returns + `max` alongside `type`/`defaultCurrency`. The old name had already outgrown + itself: the date-bucketing path reads `type` through it to tell a `date` + dimension from a `datetime` one, and the percent chain is its third consumer. + + Renderers that receive `percentScale` must scale by it rather than inferring + from the value; one that does not receive it (an older server) keeps whatever + fallback it has, so this is additive on the wire. + + **Same widget family, second fix: an empty filtered group is a measured zero.** + A measure-scoped filter can exclude every row of a group the grid still lists, + and the database reports that by omitting the group from the supplementary + result — after the merge, indistinguishable from "not measured". For a COUNT or + a SUM it _is_ measured: the answer is 0. `emptyGroupValueFor(aggregate)` + (`spec/data/aggregation-policy`) states which aggregates have an identity over + the empty set, and `queryDataset` fills it in once all supplementary merges are + done (a later measure's merge can append rows no earlier query saw). So + "0 of 12 paid" now reports `0` instead of blank, and a ratio built on it + computes to `0` instead of going null — the difference between a dashboard + saying "0% met the SLA" and saying nothing at all. `avg`/`min`/`max` keep their + null: there is nothing to average over an empty group, and flattening that to + zero would invent a measurement. + +### Patch Changes + +- 2f05139: fix(service-analytics): `compareTo` applies measure-scoped filters, so `__compare` is the same measure as the column beside it (#4820) + + A dataset measure declared with its own `filter` is scoped by running a + supplementary grouped sub-query — `combineFilters(baseFilter, measureFilters[m])` + — and merging it back by dimension key. The `compareTo` pass did not: it issued + **one** shifted query over every base measure with only the base filter as its + `where`, and never consulted `compiled.measureFilters` at all. + + For a dataset like + + ```ts + measures: [ + { name: "revenue", aggregate: "sum", field: "amount" }, + { name: "won_count", aggregate: "count", filter: { stage: "closed_won" } }, + ]; + ``` + + the current-period column was scoped and the comparison column was not — two + different measures rendered side by side under one label: + + | # | measures | where | | + | :-- | :--------------------- | :----------------------- | :------ | + | 1 | `revenue` | — | current | + | 2 | `won_count` | `{"stage":"closed_won"}` | current | + | 3 | `revenue`, `won_count` | **absent** | shifted | + + `won_count__compare` was therefore a count of **every** opportunity in the + previous window, inflated by exactly the rows the measure exists to exclude. + The error runs one way: the comparison period always looks better, so a "won + deals vs. last month" tile reads as a collapse when nothing went wrong. Only + filter-scoped measures were affected — the unfiltered ones next to them compared + correctly, which is what made it survive. + + The comparison window now runs the **same pass** as the current period — + unfiltered measures in one shifted query plus one shifted sub-query per + filter-scoped measure, merged by dimension key — through a single shared + implementation, so the two paths cannot re-diverge at the next change. The + dataset filter, the presentation's `runtimeFilter` and the measure's own filter + compose identically in both windows; the only difference between them is the + shifted `dateRange`. + + Numbers reported by existing dashboards change where a filtered measure was + compared: with 3 won deals this month against 1 won of 5 opportunities last + month, `won_count__compare` was `5` and is now `1`. + + Cost: one extra query per filter-scoped measure when `compareTo` is set. + Selections whose measures carry no filter are untouched and still compare in a + single shifted query. + + The empty-group fill (#4708) covers the new seam: a group the measure's filter + empties in the _previous_ window now reports `0` for a `count`/`sum` compare + column rather than blanking it, exactly as it already did for the current period. + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 21ef8336f0..b32be0f797 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.1", + "version": "17.0.0-rc.2", "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 f62beafad4..19fcea81ea 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,759 @@ # @objectstack/service-automation +## 17.0.0-rc.2 + +### Minor Changes + +- 2826d1e: fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + + A flow paused at an `approval` node, a deploy, then an approver clicking + Approve: the request row flipped to `approved`, the UI toasted success — and + the flow never moved. No next-stage request, no error, the record's mirrored + status frozen mid-workflow. Approval flows pause for days by design, so a + restart mid-flight is the normal case: every release could quietly zombify + every in-flight approval, with the approvers none the wiser. + + Durable suspended runs (#1518) had shipped and were not the missing piece. Two + other things were. + + **The wiring could enable a store over a table nobody had created.** Object + registration and store activation resolve different services in different + phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin + declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, + warned, and continued; `start()` then attached the DB-backed store anyway. Every + suspend failed with `no such table: sys_automation_run` into a log line nobody + read, pauses silently stayed in memory, and the next restart lost them all. + Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an + engine-less kernel must still boot); a registration missed at `init()` is + retried at `start()`, which still lands before ObjectQL's schema sync; the + store is never attached when registration did not happen, and says so at + **error** level instead of warning; the table is probed once at boot so a + broken setup surfaces there rather than one failed write at a time; and a + failed durable write of a paused run is logged at error — it is data loss in + waiting, not a warning. + + **A reported resume failure read as success.** `AutomationEngine.resume()` + answers a lost run by _returning_ `{ success: false }`, never by throwing. + `ApprovalService` discarded that return value, and `decide()` counted only a + thrown error as failure — so a decision against a dead run came back + `resumed: true`, HTTP 200. Resume failures are now classified + (`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining + `PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is + distinguishable from a store that is merely unreachable, and the raw resume + route maps them to 404 / 503 / 409. + + Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which + reads the suspension store, unlike `getRun()`, and throws rather than answering + `false` when the store is unreadable — pre-flights every flow-advancing + operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the + zombie half-state is never created rather than merely reported: the decision + fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A + resume that fails after the decision is durable can no longer be undone, but it + now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of + reporting success. A concurrent duplicate resume stays benign — the engine's + idempotency guard is doing its job — and reports through the new optional + `resumeError` field. Recall and revise-window cancellation stay non-fatal by + design (they abandon the request), but log at error with the reason instead of + swallowing it. Compositions with no automation engine attached are unaffected. + + Existing zombie requests from affected deployments (already `approved`, run + stranded) are not repaired by this change — `releaseDeadRunRequests` only + sweeps requests that are still `pending`. + +- 5293114: fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) + + A `decision` node advertised three mechanisms for splitting a path and only one + of them did anything. The other two were the ADR-0049 `declared ≠ enforced` + shape, and the pair of them shipped a guard that does not guard in + `examples/app-crm`. + + | mechanism | before | now | + | :--------------------------------------------------- | :----------------------------------------------------------------------------------------------------- | :---------------------------------------------------- | + | `edge.condition` | ✅ the only one that worked | unchanged | + | `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | + | `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | + + ## What was broken, end to end + + `crm_convert_lead_wizard` means "already converted → abort screen; otherwise → + the wizard". It ran **both**: an already-converted lead got + "This lead has already been converted" and then walked straight into the + conversion wizard behind it. Four independent silences stacked up: + + 1. the decision's first condition was authored `{lead_record.status} == +'converted'` — braces in a slot declared bare CEL, so it was string-compared + and never true; + 2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; + 3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal + discarded the branch and considered every out-edge; + 4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking + it `isDefault: true`, was a dead key. + + ## The model + + `branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` + catches whatever is left. Concretely: + + - **`isDefault` is enforced.** A default edge is traversed only when no + conditional sibling of the same source node matched, and it is no longer part + of the unconditional parallel fan-out — that distinction is the whole point of + the marker. Passed over because a real branch won, its target records the same + `skipped` step a closed gate does (#4354). + - **An unclaimable branch label warns.** Traversal still falls back to the full + edge set (a run mid-flight must not die on a metadata error) but says so, + naming the computed branch and the out-edge labels that exist. + - **A decision that declares no `conditions` reports no branch.** It used to + report `'default'` unconditionally — a label no out-edge in the repo ever + carried — which is why every decision node fell back to the full edge set. + The `'default'` sentinel survives for the case it actually describes (declared + conditions, none matched) and is now claimed by the `isDefault` edge as well + as by an edge literally labelled `'default'`. + - **`conditions[].expression` is evaluated as the bare CEL it is declared to + be.** The raw string went to the legacy `{var}` template path, where + `lead.status == 'converted'` cannot resolve and the branch is decided by + string comparison. Unlike `edge.condition` this slot carries no + `ExpressionInput` envelope — the decision descriptor is deliberately + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate + now fails loudly (ADR-0032 §1c) instead of deciding `false`. + + ## Caught at authoring time too + + Four new `os build` / `os validate` warnings, because a wrong route is silent at + run time by nature (Prime Directive #12): + + `flow-branch-label-unmatched` (the shipped shape), + `flow-decision-unconditional-branch` (a guarded decision with an unconditional + sibling — the actual hole), `flow-default-edge-with-condition` and + `flow-multiple-default-edges`. + + Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent + after it. + + ## Effect on flows that already exist + + Enforcing `isDefault` changes how a **stored** flow behaves, and the flows it + changes are mostly Studio's own. `objectui`'s flow edge inspector has always + written `isDefault: true` when you bind an out-edge to a decision's default/else + branch — into a key with zero readers, so that edge ran unconditionally, in + parallel with whichever branch actually matched. Those flows now take exactly + one branch. That is the fix, but it is a behaviour change on existing data + rather than only on newly authored metadata, so it is worth knowing before + upgrading: a flow that quietly ran two paths will now run one. + + Nothing changes for an edge that never carried the marker — `isDefault` defaults + to `false`, and an ordinary unconditional out-edge still fans out in parallel + exactly as before. + + ## The example app + + `crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the + redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One + mechanism per decision, and exactly one branch runs. + + Verified: 11 new engine/executor tests (including the reported repro in both + directions), 12 new linter tests; `@objectstack/service-automation` 577 tests + and `@objectstack/cli` 652 tests green, all three example apps build with no new + findings. + +- ac471a0: **BREAKING**: `IAutomationService.getSuspendedScreen(runId)` is now **async** — it returns `Promise` instead of `ScreenSpec | null` (#4515). + + FROM → TO for anyone calling or implementing it: + + ```ts + // caller + - const screen = automationService.getSuspendedScreen(runId); + + const screen = await automationService.getSuspendedScreen(runId); + + // implementer + - getSuspendedScreen(runId: string): ScreenSpec | null + + async getSuspendedScreen(runId: string): Promise + ``` + + One-line fix: `await` the call (the enclosing function is almost certainly already `async`), and make any test double resolve rather than return (`mockResolvedValue`, not `mockReturnValue`). + + Why it had to change: the method could only ever read the engine's in-memory hot cache, because a synchronous signature cannot consult the durable suspended-run store. `SuspendedRun.screen` _is_ persisted (`sys_automation_run.screen_json`) and `resume()` cold-reads it back, so after a process restart a still-suspended screen run could be resumed (`POST …/runs/:runId/resume` → 200) while `GET …/runs/:runId/screen` returned 404 “No pending screen for run” — the refresh-safe re-fetch failing in exactly the situation it exists for (page refresh, another device), and the rendering half of ADR-0019's durable-suspend promise missing while the resuming half shipped. + + `AutomationEngine.getSuspendedScreen` now takes the hot cache as its fast path and falls through to the store via the same loader `resume()` rehydrates from. A run that does not exist, is no longer suspended, or paused at a non-screen node still resolves to `null`, so `GET …/runs/:runId/screen` keeps returning 404 for genuinely absent runs. No sync variant of the method remains on the contract. + +- 68c02c2: fix(automation): `evaluateCondition` decides the dialect from the source, not from the caller (#4336) + + `AutomationEngine.evaluateCondition` picked its engine by asking whether an + `{ dialect, source }` **envelope** was present. A condition handed to it as a + plain string therefore never reached the CEL engine: it fell through to the + legacy `{var}` template path, which substitutes brace holes and then compares + whatever text is left — **as text**. Nothing errored, and the run was recorded + as `success`, with the failure direction depending on the predicate: + + | Handed in | Actually evaluated | Result | + | :--------------------- | :------------------------------------- | :----------------------------------- | + | `existingTask == null` | `'existingTask' === 'null'` | always **false** — gate never opens | + | `record.rating >= 4` | `'record.rating' >= '4'` → `'r' > '4'` | always **true** — branch pinned open | + + #4414 fixed the one built-in that was reaching this — the `decision` executor + now wraps `conditions[].expression` in a CEL envelope before calling. This + fixes the **evaluator**, so the next caller does not have to remember: the + dialect is now read from the source, and a condition is CEL unless it actually + contains a `{var}` hole. `evaluateCondition` is public API, so a + plugin-registered node executor evaluating its own predicate was getting the + table above with nothing to warn it. + + **The legacy `{var}` dialect keeps working** where it always did — + `{amount} > 100`, `{status} == active`, `{a.b} == 7` — and gains the two things + it was missing: + + - **A quoted literal compares as its contents.** `{status} == 'active'` used to + compare `active` against `'active'` — quotes included — and was false for + every value of `status`. It is the spelling the flow docs showed, and quoting + a string literal is what every other predicate surface requires. + - **It no longer answers `false` when it could not resolve something.** A `{…}` + hole matching no flow variable (`{lead_record.status}` — `get_record` stores + the whole row under one name, so that key never exists) and a substituted + value that is neither a boolean, a number, nor part of a comparison are + refused with the source and the offending reference attached. Both used to be + a silent `false`, which ADR-0032 §1c forbids: a predicate that cannot be + evaluated is a fault, never a quiet branch decision. + + Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap + and still throw — stating the dialect is the author saying "this is CEL". The + sniff reads the source outside string literals, so `record.label == '{pending}'` + stays CEL and compares the field. + + **Tightening to know about:** a bare string that is not valid CEL now raises + where it previously string-compared to some answer. That includes the + host-language payloads the safety tests use (`process.exit(1)`, + `require("fs")…`) — nothing executed before and nothing executes now, since CEL + has no `process`, no `require` and no arrow functions, but the failure is a + reported fault instead of a silent `false`. + +- eb4204b: feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + + The `script` executor's contract — _the named function returns a value; data I/O + stays on the flow graph_ — existed only as a comment inside the executor, while + #4354's run summary depended on it. That summary reports no record metrics for a + `script` step precisely because a pure function's writes are downstream + `create_record` / `update_record` nodes counting themselves. A function that + wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable + from the broken sweep the counters exist to detect, recorded permanently on + `sys_automation_run`. + + **The rule is now visible.** `ActionDescriptor` carries + `handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes + `'pure'`, so the action catalog, the designer palette and the reference docs + state the rule an author has to follow instead of an executor holding it + privately. + + **And a legitimate writer can opt out honestly.** A `defineStack({ functions })` + entry may declare what it does, in either shape: + + ```ts + defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: "writes" }, // declared writer + }, + }); + ``` + + A step calling a declared writer reports `unmeasuredEffect`, so the run's + `unmeasured` tally keeps the broken-sweep query + (`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that + flow. Marking _every_ `script` step unmeasured was rejected: it would blind the + detector on every flow that calls any function in order to cover the few that + break the rule. + + Nothing here is retired or renamed: a bare `functions: { fn }` entry is + unchanged and means `effect: 'pure'`. The declaration is carried end to end — + `ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the + existing `packageId` string and exposes `resolveFunctionEntry(name)`, + `objectstack build` lowers a declared entry without dropping it, and the + artifact loader re-attaches the module's callable to the declaration the JSON + carried. + + **Also fixed:** `bindHooksToEngine` returned before registering a bundle's + functions when the stack declared no hooks, so a flow-only app's + `defineStack({ functions })` reached the engine as nothing and every `script` + node calling one failed with "no function named 'x' is registered". + +- 25784cf: fix(automation,approvals): 节点类型校验推迟到插件贡献完成之后 —— approval flow 不再被误报"运行时会失败" (#4771) + + showcase 每次冷启都打印 8 条断言:这些 flow "will fail at execution time"。8 条全是假的。 + `AutomationServicePlugin.start()` 从 ObjectQL registry 拉起 flow 并**当场**校验节点类型,而 + `ApprovalsServicePlugin.start()` 在 0.8 秒后才注册 `approval` 执行器 —— 校验器在词汇表还没 + 成型的时候就下了结论。 + + 真正的代价不是噪音,是信号丢失:**真的没装 approvals 插件**的部署会得到一模一样的 8 条告警, + 所以这条 warn 无法区分"健康"和"坏掉",信噪比为 0。 + + ADR-0018 明确把节点词汇表定义为**开放、可运行时扩展**的(插件通过 + `registerNodeExecutor(type)` 贡献类型)。因此校验只在词汇表**封闭**的那一刻才成立: + + - `AutomationEngine.sealNodeTypeVocabulary()` —— 宣告词汇表封闭,对**所有**已注册 flow 跑一次 + 权威校验,每个有问题的 flow warn 一条。`AutomationServicePlugin` 在 `kernel:bootstrapped` + 调用它(严格晚于每个插件的 `start()` 和每个 `kernel:ready` handler —— 本插件自己的 + `kernel:ready` 还会再注册一批 flow,别的插件也可能在它的 `kernel:ready` 里贡献执行器)。 + - `AutomationEngine.getUnknownNodeTypeAudit(): UnknownNodeTypeAuditEntry[]` —— 同一发现的 + **状态**形态,供 host(CLI 启动摘要、健康检查)直接读,而不是去 grep 日志。与 + `getTriggerBindingAudit()` 同一套路。 + - 封闭之后 `registerFlow` **恢复即时告警**:Studio 发布 / dev reload 进正在运行的服务器时, + 词汇表确实是完整的,那句断言此时为真。所以这是时序修复,不是把告警静音。 + + 告警文案也随之改成它现在能承诺的事:"Every plugin has started, so nothing will register them + now — these nodes fail at execution time with NO_EXECUTOR",并给出补救动作。 + + 一并修掉同一缺陷类的另一半:`ApprovalsServicePlugin` 在**拿不到 automation 引擎**时,把 + "`approval` 节点没注册"记成 `info` —— 而 dev 的默认日志级别是 `warn`,于是**真降级发生时反而 + 看不见**(#4632:静默降级必须响亮)。现在是 `warn`,写明后果(该部署里每个 ADR-0019 approval + flow 都会以 NO_EXECUTOR 失败)和补救(装 `@objectstack/service-automation`)。`catch` 同时收窄 + 到"服务查找"这一步,`registerApprovalNode` 内部真出错时会以自己的身份抛出,而不再被贴上 + "no automation engine" 的错误标签;`automation` 服务存在但不接受节点执行器的分支从前**一条日志 + 都不打**,现在同样 warn。 + + **嵌入式 host 注意**:直接 `new AutomationEngine()` 而不经过 `AutomationServicePlugin` 的宿主, + 需要在自己的插件都装好之后调用一次 `sealNodeTypeVocabulary()`,才能拿到这条告警(以及之后的 + 即时校验)。 + +- fd3013a: feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343) + + A `script` node had four ways to name what it ran and only one of them ran anything. + Protocol 17 keeps that one and retires the rest. + + - **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a + line, reported success, and delivered nothing — under any configuration, installed + messaging service or not. Every bundled example used one; none of them ever sent + anything. + - **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed + a message no channel sent. (The examples did not even reach them: they passed the + payload in `inputs`, which the built-in branch never read.) + - **inline `config.script`** was recognized and **never executed** — the built-in runtime + has no server-side JS sandbox, so the node warned and completed as a no-op. + - **any other `actionType`** was shorthand for a registered-function name — a second + spelling of `config.function` — and `'invoke_function'` was a marker that named nothing + on its own. + + What remains is what worked: `config.function` (now **required**) names a registered + function, `config.inputs` feeds it, `config.outputVariable` binds its return value. + + **The replacements are three different mechanisms, not one rename.** + + | Retired | Use instead | + | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | + | `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel | + | `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | + | `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` | + + **Execute-time parse.** `script` and `subflow` now run their config through the contract + before executing, the seam #4277 gave the flat builtins — a violation refuses the node as + a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join + that seam while its legal key set depended on `actionType`: a flat parse would either + reject valid shapes or wave everything through. Converging the node is what made the + contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its + message is now `subflow 'n1': config does not satisfy the subflow contract — +config.flowName: …`. `decision` deliberately stays export-only: its one key is optional, + so a parse would check nothing. + + **Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these + keys in TypeScript is a compile error carrying the same prescription. A shorthand + `actionType` **converts into `function`** — that is what it named — unless `function` is + already set, in which case it was dead metadata the executor never reached. The other four + keys are dropped outright: nothing read them, so there is no value to preserve, and + rebuilding the intent is an authoring decision (the table above) rather than something a + mechanical rewrite can guess. + + The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired + for _misdescribing themselves_ rather than for being renamed: absorbing + `actionType: 'email'` silently would let an author keep believing the flow sends mail. The + one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) — + a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node + arrives stripped of the keys nothing read and then **refuses for naming no callable**, + where it used to log a line and report success. That flip is the behavior change to expect. + + **A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a + **lowered handler ref** (a non-empty string), the form `objectstack build` produces: the + CLI lowers every inline callable to a serialisable ref _before_ the stack is parsed (it + must — `z.function()` wraps callables and would break the ref mapping), so a built + manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was + that `defineStack({ functions })` — a documented, first-class mechanism — could not + survive a build at all. Nothing had noticed because no bundled example used it; #4343 + turns that from latent into blocking, since `config.function` becomes the only thing a + `script` node can run. `Hook.handler` already declared exactly this pair (`z.union([ +z.string(), ])`, "string, post-build / inline function, pre-build"), so this + brings `functions` onto the platform's established shape rather than inventing one. A + string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the + real functions ride in the sibling ESM module the build emits, merged by name — so + hand-authoring one registers nothing and fails loudly at execute ("no function named '…' + is registered"), never silently. + + Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`, + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed + (they described the dispatch set that no longer exists); `os validate` names a retired key + and its replacement instead of reporting a generic missing callable; and the `#3796` + alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer + describes an end state protocol 17 can reach — the rename itself is untouched. No liveness + ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is + `z.record(z.unknown())`, so these keys were never governed by one. + +- 304423e: feat(automation,migrate): `os migrate meta --stored` now covers flow rows too (#4454) + + #4327 gave the stored-metadata conversion chain a finish line for every + metadata type except `flow` — the one type where the most stored dialect + actually lives, since the graduated conversions `flow-node-crud-filter-alias`, + `flow-node-crud-object-alias`, `flow-node-notify-config-aliases` and + `flow-node-script-config-aliases` are all flow-node entries. Flow-node + conversions carry ADR-0078's open-namespace conflict guard, which has to consult + the _live_ executor registry to tell a rename from a clobber, and the metadata + layer has no way to obtain one. Flows were reported `skipped` with that reason. + They are now converted. + + **One canonicalization policy, two shapes.** + `AutomationEngine.canonicalizeStoredFlow` is the single implementation and + `registerFlow` calls it, so the load seam and the migration can never disagree + about what "canonical" means. It returns `parsed` (for execution — the + `FlowSchema.parse` + #4347 region output, schema defaults materialized) and + `storable` (for persistence). + + **`storable` excludes schema defaults, and that is the load-bearing decision.** + Measured rather than assumed: driving a pre-17 flow through all three steps + _removes_ nothing — `FlowSchema` is strict since #4001, so an unrecognized key + throws instead of being silently dropped, which means the + `graftNormalizedOperators` precedent (it exists because the _view_ parse strips + Studio-only auxiliary keys) does not transfer — and _adds_ only defaults: + `version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the + author never wrote would pin every migrated row to today's value while untouched + rows follow tomorrow's: two populations with different behaviour, which is + exactly the drift this pass exists to remove. So the write-back is the + conversion result plus the `{dialect, source}` envelopes the schema derives for + edge conditions, and nothing else. + + One subtlety worth knowing if you extend this: that envelope is a schema + transform, not a conversion, so it emits **no** notice while still changing the + body. Reading notices alone — correct for every other metadata type — would call + such a row canonical and leave it re-deriving on every boot. Both passes are + copy-on-write, so identity is the exact test for flows. + + **New: `AutomationServicePluginOptions.armRuntime`** (default `true`, so every + server, dev stack and test host is unaffected). Set `false` and the plugin + brings up the engine and the complete node registry — built-ins plus whatever + `automation:ready` contributes, because a _partial_ registry would make the + conflict guard read a live custom node type as unowned and rewrite over it — and + then stops before anything is armed: + + | Skipped when `armRuntime: false` | Why it must be | + | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | + | flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live | + | declarative connector materialization | opens real connections; an MCP provider spawns a child process | + | suspended-run wait-timer re-arm | would resume someone's paused approval mid-migration | + + `os migrate meta --stored` boots the plugin in that mode. A migration process + must not become a second server. + + A refused rename — the guard firing because the old node-type token is a live + name something else owns in this environment — fails that row loudly, naming the + token and its owner. Never a silent skip, never a clobber. A flow that cannot + canonicalize at all (a strict-schema violation, a malformed control-flow region) + is reported as failed with the parse message rather than persisted as a guess; + such a row cannot register today either, so the report is telling you about a + flow that is already broken at runtime. + +### Patch Changes + +- 5b843fb: fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations (cloud#971) + + `getMetaItems({ type: 'flow' })` decorates every served item with + `_diagnostics` (and `_draft` on a preview read). The cold-boot bind fed that + served document straight into `engine.registerFlow` → `FlowSchema.parse`, and + since #4001 closed the metadata schemas an unrecognized key **throws** instead + of being dropped — so every flow failed to register on every boot with + `unrecognized_keys: ["_diagnostics"]`. Not fatal only by luck: the + record-change plugin binds record flows a second way, so automations kept + firing behind one WARN per flow. A flow whose only binding path is this one + would have gone silently dead. + + Fixed at the read seam (`readFlowDefsFromProtocol`), not by loosening + `FlowSchema`: the payload is malformed because we decorated it, so the + producer's annotation is the producer's to remove. + + `@objectstack/spec` gains `METADATA_READ_DECORATIONS` / `stripReadDecorations` + (`kernel/metadata-read-decorations`) — the list moves out of + `metadata-protocol`, where it was module-private, so the producer and its + cross-layer consumers share one definition. `metadata-protocol` re-exports + `stripReadDecorations` unchanged; no public surface is removed. + +- 4c45be1: fix(convention): a best-effort degradation that costs DURABILITY logs `error`, not `warn` — and a gate that enforces it (#4632) + + #4420: the durable suspended-run store attached to a table that was never + created. Every write failed into a `warn` nobody read, every restart dropped all + in-flight approvals, and the process reported perfect health the entire time — + the symptom surfaced a release after the cause. #4460 raised that **one** site to + `error`. This makes it the rule, because the _class_ is what recurs. + + **The rule** (AGENTS.md → "Degradation log levels") is a question, not an + adjective, so an agent can apply it while writing the `catch`: + + > After the degradation, does the system still look "normal" from the outside, + > while something it claims is persisted has not actually landed? + > Yes → `error`. No → `warn`/`info` is right. + + An `error` here owes two things in its first line: the **consequence** (what is + not durable, and that the system will keep looking healthy anyway) and the + **fix** (the composition change that restores durability, or the explicit opt-out + that makes the degradation deliberate). Say it once, not once per failed write. + + **Sites raised to `error`** — each was reviewed individually; escalating a + functional degradation is the mirror-image failure and was deliberately avoided: + + | Where | What was silently lost | + | :----------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | + | `objectql` schema sync, per object | DDL never ran — the object stays registered, routed and rendered while its table/columns do not exist | + | `objectql` schema sync, summary | `info: Schema sync complete` printed over a pass with failures; now an `error` naming the count | + | `objectql` reload-time schema sync | a Studio edit adds a field, the UI shows it, the API accepts it, the column was never created | + | `ObjectQL.syncSchemas()` | an **empty** `catch` — marketplace install and template seeding wrote into tables this failure means do not exist, then reported success | + | `service-automation` wait-timer re-arm (4 paths) | runs stay persisted but nothing re-arms them: every approval paused before the restart hangs forever | + + **Deliberately left at `warn`** — the rule cuts both ways, and over-applying it + trains everyone to skim `error`: the batch→sequential schema-sync fallback (it + _recovers_), and "no job service is registered" on the re-arm path (a declared + absence in a host that never composed auto-resume — nothing was promised and + then broken). + + **It has teeth.** A convention that lives only in AGENTS.md is the same + "declared ≠ enforced" shape this repo keeps paying to fix, so + `pnpm check:durability-log-level` walks the AST for `catch` blocks guarding a + declared vocabulary of durability-critical operations and fails when one + degrades below `error` without rethrowing. It follows same-file helpers (so + extracting a reporter cannot quietly defeat it) and ships its own `--self-test`. + Deliberately narrow: it cannot _discover_ a new durability seam, only stop known + ones from regressing — extend `DURABILITY_CRITICAL_CALLEES` in the same PR that + fixes a new one. + + No API, schema or behaviour changes — only the level, and the text, of what + already-failing paths report. + +- f3141d8: fix(spec): a node that publishes no descriptor configSchema can now own an expression-ledger entry (#4439) + + `FLOW_NODE_EXPRESSION_PATHS` is the #4027 ledger that tells `registerFlow` and + `objectstack validate` which config keys hold expressions, and in which dialect. + Its ratchet (`config-expression-ledger.test.ts`) derives what it expects from + descriptor `configSchema` `xExpression` markers, and fails in **both** + directions — an undeclared marker, or a ledger entry nothing declares. + + `decision` / `script` / `subflow` publish **no** descriptor `configSchema` on + purpose: a published partial schema would drop the editors their hand-written + Studio forms need (the #4210 incident), so their contract lives in + `schemaless-node-config.zod.ts`. Those two rules compose into a hole — an + expression slot on a schemaless node is structurally unreachable by the ratchet, + and because the reverse direction rejects unclaimed entries, it cannot be + entered by hand either. + + `decision.conditions[].expression` sat in that hole. Its own schema says + _"Bare CEL predicate deciding this branch"_ and its own comment names `{…}` as + the #1491 trap, and no validator walked it — so `{lead_record.status} == +'converted'` passed `tsc`, passed `objectstack validate`, passed registration. + #4414 made that fail loudly at run time; this makes it fail at build time, + which is the delay #4027 exists to remove. + + ## The fix + + The ratchet now reads **both** declaration channels: + + - **descriptor `configSchema`** — unchanged, enumerated from the live registry; + - **`schemaless-node-config.zod.ts`** — the marker rides + `.meta({ xExpression })` through `z.toJSONSchema`, the same channel + `loop.collection` has used since objectui#2670. + + Spec hands the second channel over as JSON Schema + (`getSchemalessNodeConfigJsonSchemas()`, memoized, `input` mode — the shape a + descriptor's `configSchema` already is), so the ratchet walks both with the + _same_ function. No second notion of "a declared expression property", which is + the duplication a ledger exists to remove, and no `zod` dependency added to + `service-automation`. Each channel is separately asserted non-empty, so a broken + derivation on one side cannot hide behind the other's results. + + `SCHEMALESS_NODE_CONFIG_SCHEMAS` is also exported for anything else that needs + to reason about all node config contracts. Additive — objectui's + `flow-node-config` reconciliation imports each schema by name and is unaffected. + + ## The sweep + + The other schemaless slots were checked and deliberately carry no marker: + `script.template` is a template **id**, not a body; `script.inputs` / + `script.variables` / `subflow.input` are values that interpolate `{token}` — + text-with-holes, the shape essentially every node config string has, already + covered generically by `validate-flow-template-paths` and the CLI flow linter. + A `flow-template` ledger entry means something narrower: a _reference that must + resolve to a value_, like `loop.collection`. So `decision.conditions[] +.expression` is the only genuinely declared expression slot on the class — now + recorded in the ledger's header so it is not re-derived. + + ## Docs corrected + + The flows guide taught the **wrong dialect** for decision predicates in three + places (`'{order_amount} > 10000'`), plus a "braces missing in a decision + expression" warning that inverted after #4414 — and `FlowNodeSchema`'s own + `@example` did the same. All corrected to bare CEL, with the history stated so + an author with a braced predicate knows what changed and why their build now + fails. The dialect table drops from three dialects to two: predicates never take + braces, values always do. + + Verified: 13 new/updated tests across the ratchet, the engine's registration + pass and `@objectstack/lint` (including the exact app-crm predicate rejected at + both `registerFlow` and `objectstack validate`); `pnpm build`, `pnpm typecheck` + (122 tasks), `pnpm lint` and `check:docs` clean. + +- 5a84d41: fix(automation): `resume` enforces the suspended screen's declared field contract (#4477) + + A `screen` node's `config.fields` is a complete input contract — the author + declares the keys, their `required`-ness, and (via `visibleWhen`) when a field + is even asked for. The RENDER half honoured all of it: the paused result and + `GET …/runs/:runId/screen` carry `required` and `visibleWhen` intact. There was + no VALIDATION half — `POST …/runs/:runId/resume` folded whatever bag it was + handed straight into the flow variables, so a caller that skipped the dialog and + posted here directly was unconstrained by every `required` the author wrote. + Missing required fields, and keys the screen never declared, all completed the + run with `success: true`. + + Screen flows are the one place where the declared field contract is the ONLY + contract — no object schema sits behind a screen node to catch a bad bag + downstream. The platform already enforces the analogous contract everywhere else + this seam appears: action params (ADR-0104 D2), record writes (ADR-0113), + approval `decisionOutputs` (#3447). This is that rule for screen resume, built in + the same shape. + + `resume` now refuses a non-conforming submission with the new + `AutomationResult.code` `'INVALID_SCREEN_INPUT'` (a transport maps it to **400**, + as the automation domain route now does) and an `Invalid screen input: …` message + that names each violation and lists the declared field names. The refusal happens + BEFORE the suspension is consumed, so the pause stays live and the legitimate + submission still lands. + + `visibleWhen` is evaluated against the SUBMITTED values first (layered over the + run's variable snapshot), so a hidden field's `required` never fires — enforcing + it would dead-end the run at a field the user was never shown, which is #3528 + reproduced server-side. A predicate that cannot be evaluated is logged and + treated as hidden rather than visible: the client decides what the user saw, and + a broken predicate is not evidence a field was on screen. + + Scope, deliberately narrow — three shapes keep the historical pass-through: + + - an **object-form** screen (`kind: 'object-form'`), whose `fields` is empty by + construction because the client renders the object's own form and the write + path enforces that object's `required` fields itself; + - a **message-only** screen (`waitForInput: true`, no fields), which declares no + keys and so constrains none — the same pass-through `enforceActionParams` + gives a param-less action; + - `signal.output`, the node-OUTPUT namespace, which belongs to the approval-style + resume envelope rather than to the screen's collected-values channel. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/formula@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Major Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index c1d4b7284c..a357cc258e 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.1", + "version": "17.0.0-rc.2", "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 483918781f..5d651b4be0 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/service-cache +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 7da179db89..55f6f1c461 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.1", + "version": "17.0.0-rc.2", "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 681f8f3306..0e20e1aa5b 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,116 @@ # @objectstack/service-cluster-redis +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/service-cluster@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 02fca2f5f0..6da18d57d2 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.1", + "version": "17.0.0-rc.2", "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 d5464e4b42..f9790002bd 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/service-cluster +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index c3efe0a2aa..a956efae65 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.1", + "version": "17.0.0-rc.2", "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 266de1b7ed..95c468c8a8 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,271 @@ # @objectstack/service-external-datasource +## 17.0.0-rc.2 + +### Minor Changes + +- cdf4d9a: `datasource.config` is now validated against its driver's contract (#4410) + + `config` was the one authorable slot on a datasource with no gate at all. The + schema's own comment claimed "the driver's own `configSchema` is what validates + it" — nothing did: both bundled driver specs set `configSchema: {}`, no code read + the field, and the per-driver zod schemas were not even exported from the + package. So `config: { hostname: 'db.internal' }` (the key is `host`) was + accepted in silence and the datasource connected to `localhost` while the parse, + the save and the connection probe all reported success. + + `DatasourceSchema` now parses `config` against + the contract for the declared driver, and `DatasourceAdminService` + (create/update/test, the Setup wizard's path) applies the same check. Both read + one registry in `@objectstack/spec/data`, which also projects each contract to + JSON Schema for `DriverDefinitionSchema.configSchema` and the Studio connection + form, so the form offers exactly the fields the validator accepts. + + New exports from `@objectstack/spec/data`: `PostgresConfigSchema`, + `MysqlConfigSchema`, `SqliteConfigSchema`, `SqliteWasmConfigSchema`, + `MongoConfigSchema`, `MemoryConfigSchema`, plus `resolveDriverId`, + `getDriverConfigSchema`, `getDriverConfigJsonSchemaById` and + `validateDriverConfig`. A driver the platform ships no contract for (a plugin's + `com.vendor.snowflake`) keeps an unvalidated `config`. + + **Migration.** A config that was silently ignored now fails with the correction + in the message. The renames: + + | Wrote | Write instead | Driver | + | ---------------------------- | ------------- | ---------------------- | + | `user` | `username` | postgres, mysql, mongo | + | `connectionString` / `dsn` | `url` | postgres, mysql, mongo | + | `uri` | `url` | mongo | + | `file` / `path` / `database` | `filename` | sqlite, sqlite-wasm | + | `hostname` | `host` | postgres, mysql, mongo | + | `searchPath` | `schema` | postgres | + + And the relocations — keys that were never driver config: + + | Wrote in `config` | Write instead | + | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block | + | `schemaMode` | next to `driver`, on the datasource | + | `readOnly` | `external: { allowWrites: false }` — the enforced write gate. (This row said `capabilities: { readOnly: true }` until #4487's liveness audit found that key has no reader.) | + | `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand | + + Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`. + `InMemoryDriverConfig` has no field for either — the driver keeps no indexes and + evicts nothing — so both were inert. Drop them; for real indexing use a driver + that indexes. + + A postgres, mysql or mongo datasource must now name a connection target + (`database`, or a `url` that carries it). An empty `config` used to mean "the + client's own localhost default", which is the same defect in its most complete + form. + + **Also fixed, because the contract can only be enforced where it is honoured.** + These keys were declared and read by nothing; they now reach the driver: + + - `datasource.pool` is honoured by every SQL driver (it was declared, carried + into the connection spec, then overwritten with a hardcoded `{ min: 0, max: 5 }`), + and maps onto the Mongo client's `minPoolSize` / `maxPoolSize`. + - `datasource.schemaMode` reaches the driver. It was dropped between the + datasource record and the connection spec, so a `schemaMode: 'external'` + database — one ObjectStack must never run DDL against — was constructed as + `managed`. + - `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at + the record — nothing put it on the connection spec — so a TLS block configured + nothing, which is exactly what its own schema comment warns about ("a TLS + setting that never took effect looked identical to one that did"). + - postgres `schema` (knex `searchPath`), `applicationName` and `statementTimeout`. + - mongo `password`, `authSource` and `options`. A mongo datasource carrying a + `config.password` previously composed its URL with an **empty** password. + +- aee1806: feat(spec,service-datasource): graduate the driver factory's four legacy `datasource.config` `??` fallbacks into an ADR-0087 conversion (#4456) + + `createDefaultDatasourceDriverFactory` still carried four undeclared read-side + `??` fallbacks that predate the #4410 config gate: sqlite `file`/`database` + (canonical `filename`), postgres/mysql `connectionString` (canonical `url`), + postgres/mysql/mongo `user` (canonical `username`), and mongo `uri` (canonical + `url`). They were never part of the contract — no schema, form, doc or example + ever named them — and they kept working only because the reader was lenient + (AGENTS.md Prime Directive #12 debt). + + **FROM → TO, applied automatically at load** by the new conversion entry + `datasource-config-driver-key-aliases` (retired-from-load-path; replayed over + stored `sys_metadata` rows by `applyConversionsToStoredItem` and by + `os migrate meta`): + + - sqlite / sqlite-wasm: `config.file` / `config.database` → `config.filename` + - postgres / mysql: `config.connectionString` → `config.url`, `config.user` → `config.username` + - mongo: `config.uri` → `config.url`, `config.user` → `config.username` + + The mapping is driver-aware — `database` renames only under sqlite, where it + aliased the file path; for postgres/mysql/mongo it is a canonical key and is + untouched. A canonical key already present wins; the legacy alias is left + shadowed (the factory's `??` precedence, preserved). + + **Behaviour change (the deletion):** the factory now reads exactly one spelling + per key. A `DatasourceConnectionSpec` handed to the factory _directly_ with a + legacy spelling is no longer honoured — authored metadata was already rejected + by the per-driver zod gate with a rename hint (#4410), and stored runtime + datasource rows are canonicalized at every rehydration seam (including the + `sys_metadata` restore path in `DatasourceAdminServicePlugin`, which now + replays the full conversion chain), so no supported path still produces the + legacy shape. One-line fix for hand-built specs: use the canonical key from + the table above. + +- 63b33e6: A `datasourceMapping` rule is routing, not a hint — an object mapped to an + unreachable datasource no longer silently reads and writes the DEFAULT store + (#4462). + + **Observable behavior change; read this before upgrading.** Measured on `main` + during the v17 verification: map an object to a Postgres datasource with a bad + URL and the boot succeeds, `/ready` answers `200`, the datasource name appears in + **zero** log lines, `POST /api/v1/data/` returns `201` — and the + row is physically in the default store. The operator finds out by opening the + database they declared and finding it empty. ADR-0062 D2's phase-1 note called a + mapping-only datasource "decorative" to keep an example byte-for-byte unchanged; + what that bought was a silent data-placement bug. + + The fix is a pair, and each half is what makes the other correct: + + 1. **Routing stops falling through** (`@objectstack/objectql`). `getDriver` step + 2: a mapping rule that MATCHES and names a datasource with no live driver now + throws — `DatasourceUnavailableError` when the connect layer recorded a + verdict, otherwise an error naming the object, the datasource and the two + remedies. `default` still resolves onward: the default driver keeps its + natural name (#3826), so step 5 is how routing to it works. + 2. **ADR-0062 D2 grows gate (d)** (`@objectstack/service-datasource`, + `@objectstack/runtime`). A datasource a mapping rule routes at least one + object to is auto-connected at boot, and a boot-time connect failure is + **fatal** with an operator-readable reason — the same call gate (b) already + makes for an explicit `object.datasource` binding, now correct for (d) + because half 1 removed the fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still + degrades the boot instead, as for every other fatal connect. + + The mapped-object list is resolved by the boot path from the engine's own + matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) and passed to + `connectDeclared({ mappedObjects })`; the connection service never re-derives + rule matching. Two matchers drifting by one clause would connect a datasource + routing never uses, or route to one nothing connects — the defect again. + + **What to do if this breaks your boot.** It means a `datasourceMapping` rule in + your stack points at a datasource that cannot be connected. Either fix the + datasource configuration, or delete the rule — the second is what + `examples/app-crm` did in this change, and it is what keeps that example's + runtime behavior identical: its rules routed everything to an unconnected + `:memory:` datasource, i.e. to the default store by fall-through. + +### Patch Changes + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 5af1854443..ccf7f3bb0d 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.1", + "version": "17.0.0-rc.2", "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 d3b9ece799..fc2797d63c 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/service-i18n +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 995a505aaa..53126fc84b 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.1", + "version": "17.0.0-rc.2", "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 766c301bb9..449382187f 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/service-job +## 17.0.0-rc.2 + +### Patch Changes + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index f8b552f2c2..7687b0416e 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.1", + "version": "17.0.0-rc.2", "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 6324869835..08766d2d8f 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,235 @@ # @objectstack/service-knowledge +## 17.0.0-rc.2 + +### Patch Changes + +- f2445c9: feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) + + A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, + which are contracted to resolve an affected row COUNT and nothing else. That + satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / + `changes`, so before #4626 the engine fabricated a per-record event with + `recordId: ''` and `after: ` — an event every schema-compliant consumer + must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into + a real delivery naming an unidentifiable record. #4626 removed the fabrication + and published nothing instead: honest, but it left webhooks, knowledge sync and + `subscribeData` silent for every predicate write. + + Bulk writes now get their **own** contract rather than impersonating a + per-record one or going dark: + + - **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from + `DataEvent`, not a widened one: a consumer that receives + `data.records.updated` knows from the type alone that no `recordId` is + coming, instead of discovering an empty string at runtime. + - **Engine** publishes it from the `multi: true` branches of `update()` / + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A + predicate that matched **zero** rows publishes nothing (no data changed — this + is what keeps an idle background sweep from becoming an hourly "0 records" + delivery), and a driver that resolves a non-count publishes nothing and warns + rather than asserting a number it cannot verify. Per-record writes are + untouched, including a scalar `where.id` with `multi: true`, which is still a + single-record target and still emits `data.record.deleted`. + - **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are + **not** extra sources for `create` / `update` / `delete`: the delivered body + has no `recordId` and no record, so routing it to existing per-record + subscribers would hand them a payload missing every field they read — the + same class of breakage as the old `recordId: ''`, from the other direction. A + webhook that wants both subscribes to both. Bulk deliveries dedup on the + producer's event uuid, since two sweeps in the same millisecond are genuinely + different events that a timestamp-based key would collapse. + - **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the + same loud boundary validation as `subscribeData`. Kept a separate method for + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => +void` callback would recreate exactly the "typed field, `undefined` at + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened + from `data.` to `data.record.`, so an aggregate event is ignored rather than + rejected as off-contract. + - **Knowledge sync** now says out loud that a predicate write leaves its index + stale. A knowledge index is a per-record projection and `matched: 40` names no + record, so no event shape could drive it — the durable fix is reconciliation, + tracked in #4672. + + The event carries no `where` predicate. The only one available at publish time + is the middleware-composed AST, whose filter embeds the security layer's + injected row scoping (RLS, sharing) — publishing it would ship tenant scoping + internals to whatever external URL a webhook points at. + + Also pays off a measurement debt from #4655, which claimed the write-path cost + of event publishing had been measured but never published the numbers: + `packages/objectql/src/engine-data-events.bench.ts` measures it. Against an + in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, + single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless + of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its + relative cost shrinks as the match set grows. + +- 462b713: fix(objectql,client): `subscribeData` callbacks receive real `DataEvent`s — the producer now fulfils the declared contract (#4626) + + `@objectstack/spec/api`'s `DataEvent` declares top-level `id` (uuid, + required), `type`, `object`, `recordId` (required), `changes?`, `before?`, + `after?`, `userId?`, `timestamp`. But the producer (the ObjectQL engine) + published a raw `RealtimeEventPayload` envelope with `{ recordId, after, +changes }` nested under `payload` and never generated `id`/`userId`, while the + client SDK force-cast that envelope into the callback (`callback(event as any +as DataEvent)`). Subscribers who wrote `event.recordId` / `event.changes` — + exactly what the types promised — compiled green and read `undefined` at + runtime. The data-side twin of #4602. + + Producer now fulfils the contract: + + - `ObjectQL.insert()` / `update()` / `delete()` build a true `DataEvent` + (generated uuid `id`, flattened top-level fields, `userId` from the + execution context when the write names an actor) and validate it with + `DataEventSchema.parse` before publishing. The transport envelope is + unchanged (`RealtimeEventPayload`, with `payload` carrying the complete + `DataEvent`), so subscribers keep receiving `{ type, object, payload, +timestamp }` on the wire. + - A batch insert publishes one event **per record** (as before), each with its + own event id. + - **A multi-row write (`multi: true` → `updateMany` / `deleteMany`) now + publishes nothing.** Those driver methods return only an affected count, so + there is no record for a required `recordId` to name; the engine logs a + warning naming the gap instead of publishing the previous fabrication + (`recordId: ''`, `after: `), which every schema-compliant + consumer had to reject. **Consequence: webhooks and knowledge sync no longer + fire for bulk writes** — they previously fired once with an unusable body. A + real bulk event contract is tracked in #4639. + + Consumers validate or read the fulfilled shape instead of guessing: + + - `@objectstack/client`'s `subscribeData` (and therefore + `@objectstack/client-react`'s `useDataSubscription` / + `useDataSubscriptionCallback` / `useAutoRefresh`, which delegate to it) + unwraps the envelope and runs `DataEventSchema.safeParse` at the boundary. + An off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as DataEvent` + double-cast is gone, and the `recordId` option now filters on the fulfilled + event. + - `@objectstack/plugin-webhooks`' auto-enqueuer reads the required + `recordId` directly; its `recordId ?? id ?? after?.id ?? before?.id ?? +'unknown'` fallback chain is gone, and an off-contract event is dropped with + a warning rather than delivered under the literal id `'unknown'`. Delivered + webhook bodies now also carry the event's `id`/`type`/`userId`; the record + itself stays nested under `after` and the envelope keys (`object`, + `recordId`, `action`, `timestamp`) still win. + - `@objectstack/service-knowledge`'s event sync reads the record from `after` + (create/update) and the id from `recordId` (delete) for `data.record.*`. + It previously indexed the envelope itself as if it were the row, and never + resolved an id for deletes. + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 0eb4f0ebb8..9c5047a7fb 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.1", + "version": "17.0.0-rc.2", "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 3c32899532..7c8ee2a220 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/service-messaging +## 17.0.0-rc.2 + +### Minor Changes + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +### Patch Changes + +- 040ecd2: fix(service-messaging): stop the SQL outboxes from writing `updated_at` on UPDATE — `pnpm dev` no longer floods the console + + An idle dev server printed the same warning 48 times a second, forever: + + ``` + WARN Field 'updated_at' is read-only — ignoring incoming change (#2948) + ``` + + `SqlNotificationOutbox.claim()` / `.claimDigest()` and `SqlHttpOutbox.claim()` + open with an unconditional "reap stale in_flight" UPDATE — visibility-timeout + recovery that runs on every dispatcher tick whether or not any row is actually + stale — and every one of those payloads carried `updated_at`. That column is + `readonly` and owned by ObjectQL's builtin `sys_stamp_audit_update` hook, so the + value was stripped by `stripReadonlyFields` and re-stamped by the platform: a + no-op write that cost one warning per call. Three claim paths × 8 partitions × + the dispatcher's 500 ms tick = 48 identical lines a second, which buried every + real warning and error in the dev log. + + `updated_at` is now gone from every UPDATE payload in both outboxes (`claim`, + `claimDigest`, `ack`, `redeliver`); the platform hook keeps stamping it, so + stored rows are unchanged. INSERT still writes both audit columns, as `Date`s — + `created_at` is caller-owned there, and a native `TIMESTAMP` column rejects a + bare epoch-ms number on Postgres. + + That last point was also a latent bug this removes: `enqueue()` correctly used + `new Date()`, but the UPDATE paths passed epoch-ms numbers. Nothing broke only + because the strip discarded them before they reached the driver. + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 73775f77f6..806aec35ab 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index f0281f4bf1..fa79ef87e1 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/service-package +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [65f184b] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/metadata-core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index e96667f042..8224dd9ef9 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.1", + "version": "17.0.0-rc.2", "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 eb49360d20..74d348553a 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,120 @@ # @objectstack/service-queue +## 17.0.0-rc.2 + +### Patch Changes + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 41d36f5877..db5e842c7e 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.1", + "version": "17.0.0-rc.2", "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 32e5c8350a..e77b025676 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,119 @@ # @objectstack/service-realtime +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 7d12accc5e..b895f30192 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.1", + "version": "17.0.0-rc.2", "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 3ba2e76012..4c1527a13c 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,122 @@ # @objectstack/service-settings +## 17.0.0-rc.2 + +### Patch Changes + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index accb6a3f94..f3953f6c53 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.1", + "version": "17.0.0-rc.2", "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 51526f10ae..80a6153f28 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/service-sms +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index e4db4ca695..3f68486298 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.1", + "version": "17.0.0-rc.2", "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 68c6efce34..0004dc8398 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,158 @@ # @objectstack/service-storage +## 17.0.0-rc.2 + +### Patch Changes + +- 941dec4: fix(service-storage): an UNSCOPED multi-delete of `sys_attachment` is refused instead of authorized (#4757) + + `installAttachmentAccessHooks`'s `beforeDelete` gate resolved the rows a delete + matches in two ways — by `input.id`, or by `input.options.where` — and then + short-circuited with `if (!rows.length) return`. A delete carrying **neither** + an id **nor** a `where` took neither branch, so `rows` stayed empty and the gate + returned _allow_. That is not "nothing matched": nothing was ever queried. + + The engine reads the same call as a bulk delete over everything — with no + single id it seeds the delete AST as `{ object }` and hands that to + `driver.deleteMany` — so `ql.delete('sys_attachment', { multi: true })` emptied + the whole attachment table with the record-level gate having authorized exactly + zero rows. Neither layer underneath catches it: plugin-sharing composes no + row-scoping predicate for an object with no owner field (`sys_attachment`'s + provenance column is `uploaded_by`), and plugin-security only refuses callers + whose grants lack the delete bit on `sys_attachment` — an app shipping the + domain grant the attachments panel requires passes RBAC and lands here. + + The gate now fails **closed** on that shape: no id and no `where` is refused + with 403 `ATTACHMENT_DELETE_DENIED` ("Refusing an unscoped multi-delete of + attachments — scope the delete to the rows you mean"), the posture #4630 gave + `sys_comment` in `resolveTargetRows`. "Nothing to authorize" and "nothing was + ever queried" are different verdicts, and reading the second as the first is + fail-open. + + Scoped deletes are unchanged: an id-bound delete, a `where`-bound multi-delete, + and even `where: {}` (which matches every row but is a real query) still resolve + their rows and authorize each one uploader-or-parent-editor as before — a delete + that legitimately matches no row still passes. Only the predicate-less call is + newly refused. If you were relying on `ql.delete('sys_attachment', { multi: +true })` to clear the table, pass a predicate (`{ multi: true, where: {} }` + authorizes row-by-row) or perform the sweep under a system context, which + bypasses the gate as before. + +- 9fd9ae7: Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it. +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/observability@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 11b545e18f..d43bead3fd 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.1", + "version": "17.0.0-rc.2", "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 444d2afbcb..3d1dd8d673 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,4630 @@ # @objectstack/spec +## 17.0.0-rc.2 + +### Major Changes + +- e6ac4bd: BREAKING(spec): `@objectstack/spec/studio` 改名 `ActionLocationSchema` → `ActionContributionLocationSchema`;裸名 `ActionLocation(Schema)` 现在全包唯一地指 `@objectstack/spec/ui` 的应用 UI 位置词表 (#4737, #4535 C17) + + `ActionLocationSchema` 曾由 `./studio` 与 `./ui` 各自导出一个声明 —— 同名、词表完全互斥的**两个概念**(#4411 陷阱): + + | 入口 | 词表 | 语义 | 处置 | + | :------------------- | :------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------- | + | `./studio`(**改名**) | `toolbar` / `contextMenu` / `commandPalette`(3 值) | Studio IDE 外壳里插件 action contribution 出现的位置(唯一嵌入方 `ActionContributionSchema.location`) | → `ActionContributionLocationSchema`,枚举值逐字不变;新增 `ActionContributionLocation` 类型导出(旧 const 从无 type 导出) | + | `./ui`(**一字不动**) | `list_toolbar` / `list_item` / `record_header` / `record_more` / `record_related` / `record_section` / `global_nav`(7 值) | 运行中应用的 UI 上 action 渲染的位置,docblock 自宣全平台唯一真源 | 裸名唯一归属(objectui 按引用钉住 `ACTION_LOCATIONS` 并 re-export 类型族) | + + ## FROM → TO + + ```ts + // FROM —— 编译期起以 TS2305 失败(实测 objectstack / cloud / objectui 三仓零外部 importer,预期无人受影响) + import { ActionLocationSchema } from "@objectstack/spec/studio"; + + // TO —— 同一声明、同一词表,名字点明它唯一的语义 + import { + ActionContributionLocationSchema, + type ActionContributionLocation, + } from "@objectstack/spec/studio"; + ``` + + **要的是应用 UI 的 action 位置?** `import { ActionLocationSchema, type ActionLocation } from '@objectstack/spec/ui'` —— 本次未动。 + + 不保留旧名别名:在 `./studio` 上 re-export 任何一侧的 `ActionLocationSchema` 都会重开本次关闭的陷阱(要么复活双源,要么把应用 UI 词表谎报成 Studio 清单词表)。 + + ## 零元数据迁移 + + 本次只动 TS 导出名与内部 JSON Schema def 名(`studio/ActionLocation` → `studio/ActionContributionLocation`,走 `RENAMED_DEFS` 承接表,0-key carry —— 枚举 def 无 authorable properties)。作者在 Studio 插件清单里写的 `contributes.actions[].location` 取值域(`toolbar` / `contextMenu` / `commandPalette`)逐字节不变,已有清单原样解析。无 tombstone(没有 key 退役)、无 ADR-0087 conversion —— `StudioPluginManifestSchema` 是根 schema,不在 stack 树上,conversion walker 到不了它(`converge-activation-event-schema` 先例论证)。发布的 JSON Schema `$id` 随之移动:`…/studio/ActionLocation.json` → `…/studio/ActionContributionLocation.json`。 + +- ad047d2: feat(spec)!: retire the two fail-open app-area gates — `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651) + + These were **not** inert authoring keys. They were capability gates that **failed + open**: an author wrote `requiredPermissions: ['sales.admin']` on a navigation + area, got a clean parse and a stored value, and the area — with everything under + it — was served and rendered to **every user**. + + **This is a breaking change with a real migration.** Both keys are authorable + metadata keys on a `.strict()` schema, so existing `app` metadata that declares + either one now **fails to parse** with the prescription below. `authorable-surface.json` + is net **−2 keys**. This is not the "zero metadata migration" shape of the + same-window renames (#4661 C8, #4684 C9) — those kept every key. + + **The retirement kit:** + + | FROM | TO | Fix | + | --------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `app.areas[].requiredPermissions` | _(removed)_ | Delete the key. Gate each of the area's `navigation` items with `requiredPermissions` / `requiresService`, or gate the whole app with `requiredPermissions` on the AppSchema. | + | `app.areas[].visible` | _(removed)_ | Delete the key. Move the same CEL expression onto the area's `navigation` items — a navigation **item**'s `visible` is evaluated per item by the shell. | + + The retired alias spellings `visibleWhen` / `visibleOn` / `permissions` carry the + same prescriptions rather than renaming onto keys that are themselves gone. + + Run `os migrate meta --from 16` to rewrite existing sources automatically + (ADR-0087 D2 conversion `app-area-fail-open-gates-removed`, wired into the + protocol-17 D3 chain step). + + **Why they read alive — and why that made them worse than dead.** The _same key + names_ are genuinely enforced one level up and one level down: + + - **app-level** `requiredPermissions` — server-side: an app whose required + permissions the caller lacks is dropped from `/meta` entirely; + - **item-level** `requiredPermissions` / `requiresService` — stripped server-side + from the app's top-level `navigation` tree, and re-checked in the shell; + item-level `visible` is a real CEL gate in the shell. + + Three layers, of which the middle one was theatre — `filterAppForUser` reads the + app's `requiredPermissions` and then walks **only** `item.navigation`; it never + touches `item.areas`, and the client renders every area in the switcher. ADR-0078 + false compliance, the same shape as `capabilities.readOnly` (#4583). + + **Removed rather than enforced (ADR-0049), deliberately.** Enforcing area gates + is not wrong, it is unscoped: it needs semantics settled first — when an area is + filtered out, do its items disappear everywhere, or still participate in other + areas? does the server bind `user` for area-level CEL? — and a retirement must + not invent an authorization mechanism. Removing a gate that never gated is + strictly safer than shipping a major with it still declared, which would have + kept authors writing it for all of 17.x. + + **One caveat the prescription carries rather than hides:** per-item gating + _inside_ an area is enforced by the shell only, because the server does not walk + `areas`. Anything that must never reach the browser belongs in the app's + top-level `navigation` tree, or in its own app. Trading one false belief for a + weaker one would have repeated the defect this removal exists to end. + +- 7d21581: feat(spec)!: retire the six remaining `authorWarn` dead keys — book/group `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` (#4667) + + The #4488 liveness audit marked as `authorWarn` the keys whose _declaration_ + actively misleads — not merely unread, but shaped so an author reasonably + concludes they configure something. #4509 and #4583 cleared the rest; these six + are what remained, and each shipped with its own reason for reading alive. + + **The retirement kit:** + + | FROM | TO | Fix | + | -------------------------------- | ----------- | -------------------------------------------------------------------------------------------- | + | `book.translations` | _(removed)_ | Delete the key. Localize the **docs** — `doc.translations` is live on every doc render path. | + | `book.groups[].translations` | _(removed)_ | Same. Tombstoned, since `BookGroupSchema` is not `.strict()`. | + | `job.id` | _(removed)_ | Delete the key. `name` is the job's identity everywhere. | + | `translation.validationMessages` | _(removed)_ | Delete the key. Author the message on the rule: `object.validations[].message`. | + | `app.homePageId` | _(removed)_ | Delete the key. Reorder `navigation`; set `isDefault` for the root landing. | + | `app.areas[].order` | _(removed)_ | Delete the key. Reorder the `areas` array itself. | + + Run `os migrate meta --from 16` to rewrite existing sources automatically. + + **Each read alive for a different reason, and the prescriptions say which:** + + - **book `translations`** — _proximity_. `doc.translations`, two files over, same + name and shape, works on every read path. The book-level map was parsed, + stored and round-tripped, and rendered in the authoring locale to every + reader: the tree endpoint and the portal emit `label` / `description` + verbatim. + - **`job.id`** — _its own description_. "Defaults to `name` when omitted" + advertises an identity override that does not exist. `name` is the scheduling + key, the `sys_job` row key, and the `JobExecution.jobId` stamp — so two jobs + differing only in `id` were one job declared twice. + - **`translation.validationMessages`** — _the platform's own signposts, twice_. + The schema example showed a concrete override, and #3778's legacy-key + migration table steered retired `errors:` authors straight into it. **That + guidance entry is rewritten here**: retiring one dead key by pointing at + another is the defect, not the fix. + - **`app.homePageId`** — _a second source for one fact_. Not unread: objectui's + console consumed it in `resolveLandingRoute()` and it was the only thing + deciding where an app opened. (This entry first shipped saying otherwise; + corrected in #4709, which upheld the removal.) What condemns the key is its + shape — an ID cross-reference into `navigation` with no referential integrity, + falling back to the first item _silently_ when the id dangled. If "land + somewhere other than first" is ever wanted again it belongs on the navigation + item itself, not on a pointer that can miss. + - **`app.areas[].order`** — _the sibling that works_. Nav-item `order` really is + sorted; area-level order never was, and both renderers iterate the array as + authored. + + **Routes differ, deliberately.** `book.groups[].translations` and + `app.homePageId` are **tombstoned** (`retiredKey`: `never` at compile time, a + prescription at parse time) — the group schema is a plain `z.object`, where a + bare delete would have zod silently strip the key, trading one silent no-op for + another. The other four are strict deletions carrying `guidance`. Retired alias + spellings (`i18n`, `home`, `homepage`, `landingpage`, `sort`) route to the same + prescriptions rather than renaming onto keys that are gone. + + Registered as three ADR-0087 D2 conversions (`book-translations-removed`, + `job-id-removed`, `translation-validation-messages-removed`) plus an extension + of `app-dead-authoring-keys-removed`, all wired into the protocol-17 D3 chain. + + **Also corrected, both found by the gates rather than by grep:** the published + `objectstack-i18n` skill taught `validationMessages` in a copy-paste example + (an AI reproduces that verbatim), and `examples/app-todo` authored the group in + three locales — where the `en` entries merely duplicated the rule's own text and + the zh-CN / ja-JP translations had never once been rendered. + + After this, the only `authorWarn` keys left in the ledger are the two fail-open + area gates tracked in #4651, which need a decision rather than a patch. + +- b4487aa: The per-provider connector "template" cluster is removed (#4480, ADR-0049) + + `@objectstack/spec/integration` no longer exports the six per-provider + connector schemas and their sub-schema/type/example clusters (~110 exports, + 2,672 lines): + + - `DatabaseConnectorSchema` (+ `DatabaseProviderSchema`, `DatabasePoolConfigSchema`, + `SslConfigSchema`, `CdcConfigSchema`, `DatabaseTableSchema`, the three + `*ConnectorExample` constants) + - `FileStorageConnectorSchema` (+ bucket/versioning/multipart/filter configs, examples) + - `GitHubConnectorSchema` (+ repository/commit/PR/actions/release/issue configs, examples) + - `MessageQueueConnectorSchema` (+ its queue/topic/consumer configs, examples) + - `SaasConnectorSchema` (+ examples) + - `VercelConnectorSchema` (+ its deployment/domain/env configs, examples) + + The six generated reference pages under `docs/references/integration/` go with + them. + + **Why removal, not completion.** These files were the losing side of an + architecture decision the same module's live half already records. ADR-0023 + rejected hand-modelling each external system's shape inside the spec — + "re-inventing OpenAPI inside this schema" — and ADR-0097's connector protocol + does the opposite: one `ConnectorSchema`, with provider shapes coming from the + provider itself (`connector-openapi` materializes instances from an OpenAPI + document, `connector-mcp` from an MCP server). The templates hardcoded + Postgres/S3/GitHub/RabbitMQ/Vercel shapes into spec files nothing ever read: + + - `engine.registerConnector()` validates against `ConnectorSchema` from + `connector.zod.ts` — never the templates + - the `connectors:` stack collection parses `DeclarativeConnectorEntrySchema` — + never the templates + - nothing else in the monorepo, objectui included, imported any of the six + + They were also semantically wrong where they overlapped the live platform: + `DatabaseConnectorSchema` modelled "tables to sync", CDC, and `readReplicaConfig` + — a second, independent declaration of read-replica routing (the first, + `datasource.readReplicas`, was removed in #4468), complete with a `weight` + field for a load balancer that does not exist. External-database access is + datasource federation (ADR-0015), which is live and is not a connector. + + **Migration.** There is nothing to migrate: these schemas validated no stored + metadata (the `connectors:` collection never used them) and no runtime read + their output. If you imported one as a TypeScript type for your own code, + model your provider config yourself, or — the supported path — declare a + provider-bound connector instance and let connector-openapi / connector-mcp + derive the shape: + + ```ts + // before (typed against a dead spec export) + import { DatabaseConnector } from "@objectstack/spec/integration"; + + // after (the live protocol) + import { + Connector, + DeclarativeConnectorEntry, + } from "@objectstack/spec/integration"; + ``` + + The base protocol — `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the + ADR-0097 provider contract, connector-descriptor, connector auth — is + unchanged. + +- 65ca83a: feat(spec)!: 双源 C5 收敛 — `ActivationEventSchema` 归 `./kernel` 结构化形状,`./studio` re-export (#4653) + + `ActivationEventSchema` 这个名字过去在两个入口解析到**两份不同的声明**,插件作者拿到哪套校验取决于他从哪个子路径 import(#4411 陷阱): + + | 入口 | 声明 | 作者写的样子 | + | :------------------------- | :------------------------------------------------------- | :----------------------------------------- | + | `@objectstack/spec/kernel` | `z.object({ type: z.enum([...]), pattern: z.string() })` | `{ type: 'onCommand', pattern: 'my.cmd' }` | + | `@objectstack/spec/studio` | `z.string()` | `'onCommand:my.cmd'` | + + 两侧都在作者面上(kernel 侧嵌在 `DynamicLoadRequest.activationEvents`,studio 侧嵌在 `StudioPluginManifest.activationEvents`,后者正是 `defineStudioPlugin` 的入参),所以没有"死侧"可删。v17 统一到**结构化形状**:`./studio` 现在 re-export `./kernel` 的那一份声明,平台只剩一套激活词表。 + + **为什么是结构化的那一侧赢。** 字符串那一侧更眼熟(照搬 VS Code),但它什么都不校验:`z.string()` 接受 `''`、`'banana'`,以及真正要命的 `'onMetadatType:flow'` —— 这个文件文档里列的词表(`*`、`onMetadataType:`、`onCommand:`、`onView:`)只活在散文里,拼错永远静默通过。结构化形状用 enum 在**创作时**就把触发器类型钉死,这才是声明它的意义。 + + ## FROM → TO + + `activationEvents` 的每一项从字符串变成对象。冒号前的段成为 `type`,冒号后的段成为 `pattern`: + + ```ts + // FROM (v16 及以前,@objectstack/spec/studio) + defineStudioPlugin({ + id: "objectstack.flow-designer", + name: "Flow Designer", + activationEvents: ["onMetadataType:flow"], + }); + + // TO (v17+) + defineStudioPlugin({ + id: "objectstack.flow-designer", + name: "Flow Designer", + activationEvents: [{ type: "onMetadataType", pattern: "flow" }], + }); + ``` + + 逐条对照: + + | FROM | TO | + | :--------------------------------- | :------------------------------------------------------- | + | `'*'` | `{ type: 'onStartup', pattern: '*' }` | + | `'onMetadataType:flow'` | `{ type: 'onMetadataType', pattern: 'flow' }` | + | `'onCommand:myPlugin.doSomething'` | `{ type: 'onCommand', pattern: 'myPlugin.doSomething' }` | + | `'onView:myPlugin.myPanel'` | `{ type: 'onView', pattern: 'myPlugin.myPanel' }` | + + `StudioPluginManifest.activationEvents` 的默认值随之从 `['*']` 变为 `[{ type: 'onStartup', pattern: '*' }]`。`'*'` 没有拿到独立的 `type`:它一直就是"立即激活",而 kernel 侧的 `onStartup` 本来就是这个意思,再加一个枚举值只会造出两个同义词。 + + ## 词表 = 两侧并集,没有能力被静默拿掉 + + enum 取**两侧 v17 前词表的并集**,共 9 个值: + + | 值 | 来源 | + | :--------------- | :------------------------------------------------------------ | + | `onCommand` | kernel enum + studio 文档 `onCommand:myPlugin.doSomething` | + | `onRoute` | kernel enum | + | `onObject` | kernel enum | + | `onEvent` | kernel enum | + | `onService` | kernel enum | + | `onSchedule` | kernel enum | + | `onStartup` | kernel enum;同时是 studio `'*'` 的落点 | + | `onMetadataType` | studio 文档/测试 `onMetadataType:object` —— kernel 原本没有 | + | `onView` | studio 文档/测试 `onView:myPlugin.myPanel` —— kernel 原本没有 | + + **未采纳**:cloud-v1 未发布的 marketplace runtime 里的 `priority`、`onInstall`、`onWebhook`。四仓无人读它们,而新增一个 declared-but-unenforced 的键正是 ADR-0049 在清的债 —— 等真有执行点再单独提。 + + ## 迁移是手工的,但失败是响亮的 + + **没有随附 ADR-0087 conversion,因为写不出能跑到的那一个。** conversion 层(`applyConversions`)接在 `normalizeStackInput` 上,只走 stack 树;而 `StudioPluginManifestSchema` 和 `DynamicLoadRequestSchema` 都是**根 schema**,没有任何父 schema 嵌入它们(前者由 `defineStudioPlugin` 直接 parse,后者是运行时请求载荷),都不在 stack 里。伪造一个永远不会命中的 conversion 只会制造"已自动迁移"的假象。 + + 手工迁移步骤:按上表把每个字符串改写成 `{ type, pattern }`。**漏改会在 parse 处响亮失败** —— `StudioPluginManifestSchema` 是 `strictObject`,字符串遇到对象 schema 直接抛错,不存在静默吞掉或强制转换。 + + ## 不要与同窗口的 #4509 / #4664 退休项混淆 + + v17 同窗口的 #4664 退休了五个键。其中 **`app.contextSelectors[].placement`** 与本条变更**毫无关系**,但很容易被读成有关系 —— 那条退休说明里写着「`location` 曾是 `placement` 的别名」,而 Studio 插件的面板贡献点**恰好也有一个 `location` 键**: + + | | 被 #4664 退休的 | 本次变更**未动**的 | + | :--- | :----------------------------------------------------------- | :---------------------------------------------------------- | + | 键 | `ui/App.contextSelectors[].placement`(`location` 是它的别名) | `studio/PanelContribution.location` | + | 语义 | app 的上下文选择器渲染在哪(`sidebar_header` / `topbar`) | Studio 插件的辅助面板停靠在哪(`bottom` / `right` / `modal`) | + | 状态 | 已删除 | **原样保留**,仍是可作者化键 | + + 两者在不同 schema 上、取值域不同、互不相关。写 Studio 插件的作者**不需要**因为 #4664 去动 `contributes.panels[].location`。 + + 其余四个退休键(`mapping.extractQuery` / `mapping.errorPolicy` / `mapping.batchSize` / `app.contextSelectors[].includeAll`)与 `activationEvents` 无任何语义交叉;同窗口的 #4668(ADR-0119 D2 migration journal)亦然。 + + ## 其它影响 + + - `@objectstack/spec/studio` 现在**额外导出** `ActivationEvent` 类型(此前只有 schema),与 `./kernel` 指向同一份声明。 + - `ActivationEventSchema` 从 `dual-source-exports.baseline.json` 移除,基线 22 → 21。 + - 零可作者化 key 消失、零 tombstone:kernel 的 `ActivationEvent:type` / `:pattern` 原样存活,`studio/ActivationEvent` 侧新增 2 个 key(字符串没有 key,对象有),属 `gen:schema` 允许的**新增**。 + +- c6d1cb4: refactor(spec,drivers)!: retire `IDataDriver.findStream` — a required method with no caller, whose two main implementations did the opposite of what it promised (#4484, ADR-0049 enforce-or-remove) + + `findStream` was a **required** method on the driver contract — every driver and + every test double had to implement it — documented as the read + + > Optimized for large datasets to avoid memory overflow. + + Three things were true about it at once, and each is worse in the light of the + others. + + **Nothing called it.** Not the query engine (there is no `stream` entry on it), + not REST export, not import, not any bulk-read path. Repo-wide, outside the + contract declaration and the three driver implementations, every single hit was + a test double — and roughly twenty of those satisfied the required method like + this: + + ```ts + findStream() { throw new Error('not implemented'); } + ``` + + Twenty stubs that throw, across four packages, for years, and no test ever went + red. That is not an anecdote about test hygiene; it is the proof of absence. A + method whose every double throws is a method nothing reaches. + + **Two of the three implementations inverted its one guarantee.** `SqlDriver` and + `InMemoryDriver` both did this: + + ```ts + const results = await this.find(object, query, options); // ← the entire result set + for (const row of results) yield row; + ``` + + The whole table is resident in memory before the first `yield`. A caller who + believed the doc comment and reached for `findStream` precisely because a result + set was too large would have hit the overflow it existed to prevent, at exactly + the scale where it mattered. `SqlDriver` carried a `TODO: Use Knex .stream()` + admitting it. + + **The one real implementation dropped a parameter.** `MongoDBDriver._findStream` + did walk a cursor — but it was the only read in that driver never routed through + `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently + discarded `query.fields`. (#4459 unified `find`/`findOne` onto `buildFindOptions` + and recorded in its TSDoc that `_findStream` was left out. This removal subsumes + that divergence rather than fixing it — there is nothing left to fix it for.) + + Rather than manufacture a caller to justify three implementations, the method is + retired. If a cursor-based read is wanted, it should arrive **with** the caller + that needs it, so the contract can be shaped by a real requirement instead of + being reverse-engineered from a doc comment nobody could test. + + **Migration.** + + | Wrote | Write instead | + | ---------------------------------------------------------- | ---------------------------------------------------------- | + | `for await (const row of driver.findStream(obj, q)) { … }` | page `driver.find(obj, { ...q, limit, offset })` in a loop | + | `findStream(…) { … }` on your own driver | delete the method (see below) | + | `findStream() { throw new Error('ni'); }` in a test double | delete the line | + + Paging `find()` is not a downgrade from what `findStream` actually did: on SQL + and memory it is strictly better (bounded pages instead of one full + materialisation), and the paged read is the one with an **enforced** guarantee — + `IDataDriver.find` requires a total order across the whole walk, checked by the + shared `PAGINATION_CASES` / `PAGINATION_UNORDERED_CASES` fixtures in + `data/pagination-conformance.ts`. `findStream` never had a conformance case at + all. + + **Driver authors: nothing breaks on you.** An implementation left in place still + compiles — an extra method is not an error on a class or a widened object — it is + simply never reached, so deleting it is cleanup you can do whenever. The break is + on the **caller** side: `driver.findStream(...)` no longer type-checks, and there + were no callers. + + **No tombstone, deliberately.** The other v17 retirements tombstone their key so + authoring it fails loudly with a prescription. That would be noise here. + `DriverInterfaceSchema` describes a contract that code _implements_; nothing in + either repository ever ran a driver object through `.parse()`, so a + `retiredKey()` there would carry its prescription to no one. The channel that can + carry it is `tsc`, and `tsc` reports it where it is actionable — at a call site. + The key is removed from the schema and from `IDataDriver`, and the retirement is + registered as the `data-driver-find-stream-retired` semantic entry in the + protocol-17 chain step (ADR-0087 D3), so `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool all carry it. There is no + `os migrate meta` step: a driver is code, never stack metadata, so the chain has + no source to rewrite. + + **Left standing on purpose:** `DriverCapabilities.streaming`, the capability flag + whose only referent was this method. It has no readers either (and the values + written into it were already wrong — `SqlDriver` declared `streaming: false` + while implementing `findStream`, `InMemoryDriver` declared `true` for the + copy-everything version), but removing a key from the capabilities literal breaks + every driver that writes it, third-party included, and the same audit should + cover the other ~30 flags in one pass rather than one at a time. Tracked as + #4634. + +- 36030ff: **BREAKING**: `DataEventType` drops `data.field.changed` — it had no producer (ADR-0049 enforce-or-remove, #4673) + + `data.field.changed` was declared in the `DataEventType` enum and emitted by + nothing. The engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` + and (since #4639) `data.records.{updated,deleted}`; no other producer exists in + either repository. A subscriber that switched on `data.field.changed` held a + branch that could never run — and because the surrounding `switch` still + compiled, nothing ever reported the gap. That is ADR-0078's silently-inert + declaration, on the event vocabulary. + + It also could not have been implemented against this contract as written: + `DataEventSchema` is record-shaped (`recordId`, `changes`, `before`, `after`) + with no `field` / `oldValue` / `newValue` slot, so the member advertised a + granularity the payload has no room for. + + **FROM → TO** + + | FROM | TO | + | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- | + | `type: 'data.field.changed'` | `type: 'data.record.updated'`, reading the per-field detail from the payload's `changes` map (with `before` / `after` for surrounding state) | + + **The one-line fix** — delete the dead branch and read `changes` off the update + event: + + ```ts + // BEFORE — never ran; no producer ever sent this event + if (event.type === "data.field.changed") { + onFieldChange(event); + } + + // AFTER — the changed fields have always ridden on the record event + if (event.type === "data.record.updated") { + for (const [field, value] of Object.entries(event.changes ?? {})) + onFieldChange(field, value); + } + ``` + + Removing that branch changes no observable behaviour — it never executed — so + this is deleting code that could not run, not rebuilding a capability. Note the + replacement is one event per write rather than N events on a wide table. + + **The retirement kit:** + + - **Schema** — the member is gone from `DataEventType` (`api/events.zod.ts`), + with an in-schema comment recording what was removed and what the live + mechanism is. Deliberately **no `retiredKey()` tombstone**: a removed enum + VALUE cannot carry a fix-it prescription the way an authorable object key + can (the same limit the sharing-rule `full` retirement hit). The enforced + channels are `tsc`, which fails any consumer still naming the value in a + `DataEventType` position, and the enum parse, which now rejects the name + instead of accepting an event that never arrives. + - **ADR-0087 D3 semantic migration** — `data-field-changed-event-retired` in + `migrations/registry.ts` (step 17), carrying the reason and acceptance + criteria. Registered as a **semantic TODO rather than a D2 conversion** + because this is a runtime EVENT surface: no stack, example or template + authors an event name, so there is no source for `os migrate meta` to + rewrite. (Webhooks subscribe through the separate authorable + `WebhookTriggerType`, whose vocabulary was already trimmed to producers that + exist, #3196.) + - **No liveness-ledger entry** — the ledger governs authorable metadata types + (`object`, `field`, `flow`, …); `DataEvent` is a runtime payload contract and + has no ledger file. `check:liveness` and `check:empty-state` pass unchanged. + - **No `authorable-surface.json` movement** — that ratchet tracks authorable + _keys_ (`api/DataEvent:type` and friends), not enum members, so the key list + is unchanged and gates (a)/(b) correctly stay silent. + - **Tests** — `api/events.test.ts` pins the narrowed `.options`, asserts the + retired name no longer parses, and pins the FROM → TO replacement (that + `data.record.updated` really does carry `changes` / `before` / `after`). + - **Docs** — `content/docs/references/api/events.mdx` and + `docs/protocol-upgrade-guide.md` regenerated. + + If a genuine per-field change stream is ever wanted, it earns its own honest + contract — the precedent #4639 set for bulk writes — rather than reclaiming + this slot. + +- e533b0b: feat(spec)!: retire `datasource.capabilities` — eleven flags nothing read, one of them a safety claim (#4583) + + `DatasourceCapabilities` declared eleven booleans — `transactions`, seven `query*` + flags, `joins`, `fullTextSearch`, `readOnly`, `dynamicSchema` — all strict-guarded, + all read by nothing. Pushdown is decided by the runtime driver's own `supports.*` + object, a different mechanism entirely, so a datasource declaring + `queryAggregations: false` never once changed which engine path ran. The block is + removed rather than bridged: there was nothing on the other side to connect it to. + + **`readOnly` is why this is not tidy-up.** It reads as a safety property and was + authored as one — the shipped CRM example labelled a datasource "CRM Analytics Read + Replica" on the strength of it, while the datasource accepted writes exactly like the + primary. The key had already been MOVED twice toward somewhere it might be enforced, + out of `config` in #4410 and into `capabilities` in #4465, and was inert at every + address. This removes it instead of moving it a third time. + + **Removing it does not hand you a working replacement, and the rejection says so.** + The one enforced datasource-wide write gate is `external.allowWrites: false`, and it + applies only to a FEDERATED datasource — `assertWriteAllowed` returns early for a + `managed` (or unset-`schemaMode`) datasource, so that key would be equally inert for a + local database. **A managed datasource has no read-only gate at all**; that gap is + #4584, deliberately not invented here. Until it is answered, enforce read-only where + it is real: grant the connection SELECT-only at the database. + + FROM → TO: + + ```ts + // before — parsed cleanly, changed nothing + defineDatasource({ + name: 'analytics', driver: 'sqlite', config: { filename: ':memory:' }, + capabilities: { readOnly: true, queryAggregations: true }, + }) + + // after — delete the block; for a FEDERATED datasource the enforced gate is: + defineDatasource({ + name: 'warehouse', driver: 'postgres', config: { … }, + schemaMode: 'external', + external: { allowWrites: false }, + }) + ``` + + `os migrate meta --from 16` rewrites it automatically (ADR-0087 conversion + `datasource-capabilities-removed`). Both `DatasourceSchema` and + `DriverDefinitionSchema` are `.strict()`, so a leftover key is a loud rejection + carrying the prescription — never a silent strip. + + Also fixed: `READ_ONLY_BELONGS_ON_DATASOURCE`, the prescription every SQL driver + shares for a `readOnly` written inside `config`, was still sending authors _to_ the + removed key. It now names the enforced gate and states plainly where that gate does + not apply — a prescription that lands on an inert key manufactures exactly the belief + it was meant to correct. + + The `datasource` liveness ledger drops from 20 dead properties to 9 (remaining: + `healthCheck` ×3, `retryPolicy` ×4, `external` ×2 — batches B/C/D of #4583). + +- c13350b: feat(spec)!: retire `external.label` and `external.requirePermission` (#4583 batch D) + + Two keys on the federation block, both read by nothing. + + **`external.label`** — nothing rendered the federation block's own label. Setup → + Datasources renders the datasource's **top-level** `label`, which every datasource already + has, so this was a second display name that never displayed. The showcase example declared + both; it now declares only the one that shows. + + **`external.requirePermission`** — no authorization check ever consulted it. A permission + named here gated nothing: access to a federated datasource's data is governed by the + ordinary object permission sets and RLS, exactly as for a managed datasource. Naming a + permission that is never required is the false-compliance shape ADR-0049 exists to remove + — it reads like an access control and is one only in the author's head. + + FROM → TO: delete `external.label` (use the top-level `label`); delete + `external.requirePermission` and grant or withhold the object permissions instead. + `os migrate meta --from 16` removes both automatically (conversion + `datasource-inert-blocks-removed`). + + With these, the `datasource` liveness ledger reaches **zero dead properties** — down from + the 20 it was seeded with in #4487, the highest dead ratio of any governed type. + +- c13350b: feat(spec)!: retire `datasource.healthCheck` — no probe loop ever existed (#4583 batch C) + + Three keys — `enabled`, `intervalMs`, `timeoutMs` — declared, strict-guarded, read by + nothing. No health-check loop was ever scheduled, so `enabled: true` enabled nothing and + the two timeouts bounded nothing. + + Connection liveness is probed **on demand** through the driver handle's `ping()` / + `checkHealth()`, which the datasource admin service calls for "Test connection". That is + the mechanism — it needs no configuration here and never read this block. + + Note what it is NOT to be confused with: `external.validation.checkIntervalMs` is the one + recurring datasource timer, and it checks **schema drift** on a federated datasource, not + connection liveness. It is unaffected. + + FROM → TO: delete the block. `os migrate meta --from 16` removes it automatically + (conversion `datasource-inert-blocks-removed`). + +- 9ca2d85: `datasource.readReplicas` is removed (#4468, ADR-0049 enforce-or-remove) + + It described replica connections nothing ever opened. `ConnectableDatasource` + and `DatasourceConnectionSpec` carry no replicas field, the driver factory never + reads the key, and no query path distinguishes a read from a write — the + platform has no read/write splitting at all, so every statement always went to + the primary no matter what was declared here. + + **Migration.** + + | Wrote | Write instead | + | ------------------------------------------ | -------------- | + | `readReplicas: [{ host: 'replica-a', … }]` | delete the key | + | `replicas: [ … ]` (the alias) | delete the key | + + There is no target to move to, because there is no read-replica routing to move + to. If you need replica reads today, front them behind a single endpoint — + pgpool, ProxySQL, an RDS reader endpoint — and point `config` at that endpoint. + That is the one read-scaling path that works, and it worked before this key was + removed too. + + Run `os migrate meta --from 16` to strip it from your sources; the + `datasource-read-replicas-removed` conversion emits one notice per datasource. + Authoring it now fails the parse with the same prescription. + + **Why this one is worth reading about.** #4410 closed the `datasource.config` + gap and, in passing, extended the new per-driver validation over each + `readReplicas` entry — reasonably, since replicas carry the same shape. The + result was a slot that had every marker of a working feature: declared with a + doc comment, `.strict()`-guarded against typos at the top level, and + field-by-field validated against the driver's contract underneath. A replica + block with a misspelt `hostname` was rejected by index, naming the canonical + key. + + None of that is evidence of a consumer, and all of it reads like one. That is + the specific trap ADR-0049 exists for: rigor is cheap to add to a dead slot and + expensive to distinguish from life. Two independent surfaces had drawn the + wrong conclusion — this validation, and objectui's datasource preview, which + rendered a "2 read replicas" pill confirming the config to the author while + nothing routed a single read. The preview goes with the key (objectui side, + same change); `packages/spec/liveness/README.md` has the standing rule it + violated ("an authoring/preview renderer is NOT a runtime consumer"). + + Read-replica routing remains unbuilt. It is tracked as a feature request rather + than left as a schema key that looks like one. + +- c13350b: feat(spec)!: retire `datasource.retryPolicy` — nothing ever retried on it (#4583 batch B) + + Four keys — `maxRetries`, `baseDelayMs`, `maxDelayMs`, `backoffMultiplier` — declared, + strict-guarded, and read by no connect or query path. Connection failure is handled by + the boot policy in the datasource connection service (degraded boot, or `bootCritical` + fail-fast); nothing retries on a schedule, so setting `maxRetries: 5` changed nothing. + + **Do not "fix" this by renaming keys.** `hook.retryPolicy` and `job.retryPolicy` ARE + enforced — but they are a different key on a different type, and they spell the delay + `backoffMs`, not `baseDelayMs`. That very inconsistency is the evidence nothing read the + datasource one: no code in the repo reads both spellings. Moving these values onto a hook + or a job only makes sense if you actually want that hook or job retried. + + FROM → TO: delete the block. `os migrate meta --from 16` removes it automatically + (conversion `datasource-inert-blocks-removed`). `DatasourceSchema` is `.strict()`, so a + leftover `retryPolicy` is a loud rejection carrying this prescription — never a silent + strip. + +- 891d345: BREAKING(spec): 退役 L1「Simple Sync」整层(`@objectstack/spec/automation` 的 `DataSyncConfig` 一族,17 个导出名),并把 `@objectstack/spec/integration` 的冲突策略枚举改名 `ConflictResolution` → `ConnectorConflictResolution`;裸名 `ConflictResolution` 现在全包唯一地指 `@objectstack/spec/ui` 的离线同步概念 (#4738, #4535 C13+C15) + + `DataSyncConfig(Schema)` 曾由 `./automation` 与 `./integration` 各自导出一个声明,`ConflictResolution(Schema)` 更是**三个入口三个声明**(#4411 陷阱): + + | 名字 | 入口 | 词表/形状 | 处置 | + | :------------------- | :---------------------------- | :------------------------------------------------------------- | :----------------------------------------------------- | + | `DataSyncConfig` | `./automation`(**删除**) | 19 键,direction=push/pull,batchSize 默认 100 | 随 L1 整层退役 | + | `DataSyncConfig` | `./integration`(**保名不动**) | 9 键,direction=import/export/bidirectional,batchSize 默认 1000 | 唯一真源(`ConnectorSchema.syncConfig` 活解析路径) | + | `ConflictResolution` | `./automation`(**删除**) | `destination_wins` / `merge` 等 5 值 | 随 L1 整层退役 | + | `ConflictResolution` | `./integration`(**改名**) | `target_wins` 等 4 值 | → `ConnectorConflictResolution(Schema)`,枚举值逐字不变 | + | `ConflictResolution` | `./ui`(**一字不动**) | `client_wins` / `server_wins` / `manual` / `last_write_wins` | 裸名唯一归属(objectui 实活消费) | + + **automation 侧是叙事层,不是实现**:L1「Simple Sync」只存在于 `SYNC_ARCHITECTURE.md` 的三层故事里 —— 三仓(objectstack / cloud / objectui)import 语句级零消费者,没有任何引擎解析或执行过 `DataSyncConfig`,8 个 def 从元数据根真 Zod 图不可达(#4650 门禁实测)。整文件删除:`DataSyncConfig(Schema)`、`ConflictResolution(Schema)`、`SyncDirection(Schema)`、`SyncMode(Schema)`、`DataSourceConfig(Schema)`、`DataDestinationConfig(Schema)`、`SyncExecutionStatus(Schema)`、`SyncExecutionResult(Schema)`、`Sync` 工厂。 + + ## FROM → TO + + ```ts + // FROM —— 编译期起以 TS2305 失败(实测三仓零命中,预期无人受影响) + import { + DataSyncConfig, + ConflictResolution, + Sync, + } from "@objectstack/spec/automation"; + ``` + + - 若你要的是**连接器同步策略配置**(唯一活着的服务端 sync 面): + + ```ts + // TO —— ConnectorSchema.syncConfig 的类型;裸名保持不变 + import { + DataSyncConfig, + ConnectorConflictResolution, + } from "@objectstack/spec/integration"; + ``` + + - 若你要的是**多源转换管道**:`import { ETLPipeline } from '@objectstack/spec/automation'`。 + - 若你要的是**客户端离线冲突策略**:`import { ConflictResolution } from '@objectstack/spec/ui'`(本次未动)。 + + ```ts + // FROM —— integration 侧旧名,编译期起以 TS2305 失败 + import { + ConflictResolution, + ConflictResolutionSchema, + } from "@objectstack/spec/integration"; + + // TO —— 同一声明、同一词表,只是名字带上了域前缀 + import { + ConnectorConflictResolution, + ConnectorConflictResolutionSchema, + } from "@objectstack/spec/integration"; + ``` + + **零元数据迁移**:integration 改名只动 TS 导出名,`connectors[].syncConfig.conflictResolution` 的取值域(`source_wins` / `target_wins` / `latest_wins` / `manual`)逐字节不变,已发布的 connector 元数据原样解析(def 改名走 `RENAMED_DEFS` 承接表,0-key carry);automation 删除侧没有任何存量元数据可迁 —— 无解析站点即无作者,conversion 写不出能跑到的(不在 stack 树,`converge-activation-event-schema` 先例论证)。相邻雷勿踩:`@objectstack/spec/api` 的 `ConflictResolutionStrategy`(路由冲突,`error` / `priority` / `first-wins` / `last-wins`)是第四个同族概念、不同名,本次未动。 + +- d9fa683: refactor(spec)!: retire the 31 inert `DriverCapabilities` bits — declared by every driver, read by nothing (#4634, ADR-0049) + + The #4484 findStream close-out left one loose end: `DriverCapabilities.streaming` + described a contract method that no longer exists — and a full liveness audit of + the record (#4634, across objectstack + cloud, objectui confirmed clean) found + `streaming` was not the exception but the rule. Of 34 declared bits, **three** + have a decision-making reader and **thirty-one** were written by every driver + and consulted by no engine, planner, REST layer or renderer: + + - Their `.describe()` strings promised engine adaptation that was never built + ("If false, ObjectQL will fetch all records and filter in memory" — no such + fallback ever keyed off the bit). + - Zero readers let values go WRONG unnoticed: `SqlDriver` declared + `streaming: false` while implementing `findStream`; `InMemoryDriver` declared + `streaming: true` over a full-table read — the exact inverse of the guarantee. + - The real mechanism everywhere else is **method presence**: transactions gate + on `driver.beginTransaction`, aggregate pushdown on + `typeof driver.aggregate === 'function'`, schema sync on + `typeof driver.syncSchema === 'function'`, and the REQUIRED CRUD/bulk methods + are called unconditionally. + + Survivors (each with a named reader — the bits method presence cannot carry): + + | bit | reader | + | ---------------------- | ---------------------------------------------------------------------------------------- | + | `queryDateGranularity` | engine aggregate dispatch (`engine.ts`), `checkDateBucketParity` (`@objectstack/verify`) | + | `autonumber` | engine defers autonumber generation to the driver (`engine.ts`) | + | `batchSchemaSync` | engine ANDs it with `syncSchemasBatch` presence (`engine.ts` / `plugin.ts`) | + + Migration (FROM → TO): + + - Any of the 31 bits (`create`/`read`/`update`/`delete`, `bulkCreate`/ + `bulkUpdate`/`bulkDelete`, `transactions`/`savepoints`/`isolationLevels`, + `queryFilters`/`queryAggregations`/`querySorting`/`queryPagination`/ + `queryWindowFunctions`/`querySubqueries`/`queryCTE`/`joins`, + `fullTextSearch`/`jsonQuery`/`geospatialQuery`/`streaming`/`jsonFields`/ + `arrayFields`/`vectorSearch`, `schemaSync`/`migrations`/`indexes`, + `connectionPooling`/`preparedStatements`/`queryCache`) in a `supports` + literal or a `DriverConfig.capabilities` object → **delete the key**. Each is + tombstoned (`retiredKey()`), not silently stripped: authoring one is a `tsc` + error against `IDataDriver.supports` and a parse error carrying the per-key + prescription, which names the mechanism that actually decides the behaviour. + - `batchSchemaSync` dropped its `.default(false)` for `.optional()` — absence + already meant `false` at both readers, so `supports: {}` is now a valid, + minimal advertisement. If you read `capabilities.batchSchemaSync` from a + _parsed_ config and relied on the materialised `false`, treat absence as + `false` (both engine readers always did). + - Driver packages: `InMemoryDriver.supports` is now `{}`, + `MongoDBDriver.supports` is `{ batchSchemaSync: true }`, `SqlDriver.supports` + is `{ queryDateGranularity, autonumber: true, batchSchemaSync: false }`. + Reading a removed bit off these literals no longer type-checks — and no code + in any repository did. + - A future capability (streaming reads, vector search, …) returns **with its + caller and its reader in the same change** — the enforce route of ADR-0049 — + never as a dangling boolean. + + The retirement kit: 31 `retiredKey()` tombstones on the non-strict schema + (parse + `tsc` both audible; the schema IS parsed via + `DriverConfigSchema.capabilities` and its SQL/NoSQL extensions); ADR-0087 D3 + semantic migration `driver-capabilities-inert-bits-removed` (a driver is CODE, + never stack metadata — `supports` lives in driver classes and `DriverConfig` + is plugin TS configuration, so there is no stored row or stack source for a D2 + conversion to rewrite; the stack-tree neighbour `datasource.capabilities` was + retired separately in #4583); baselines (`authorable-surface.json` [RETIRED] + lines, `json-schema.manifest.json`) regenerated deliberately; compiler-API pin + asserting every retired bit is unwritable (`undefined`) and every live bit is + not, sabotage-verified both ways (S1 schema resurrection, S2 driver literal + resurrection). + + No runtime behaviour changes — that impossibility is the point: every removed + bit had zero readers, and the three live bits keep theirs. + +- 3c7bcc0: feat(spec)!: converge the 11 contracts-vs-domain dual-source type names (#4538) + + `packages/spec/src/contracts/` hand-wrote parameter/result interfaces whose + names collided with same-named zod-derived types in the domains — the #4411 + trap, tracked as 11 rows of `dual-source-exports.baseline.json`. Each name was + judged individually against a three-repo import-level scan (framework, cloud, + objectui): which declaration actually flows at runtime decides the direction. + All 11 rows are deleted from the baseline; no name below is exported twice + anymore. + + **Converged — `./contracts` now re-exports the domain zod type (same + declaration on both entries, imports keep compiling from either):** + + - `NotificationChannel` → `system/notification.zod`'s + `z.infer` (member sets were identical). + - `ValidationResult` → `kernel/plugin-validator.zod` (shapes were identical). + - `HealthStatus` → `kernel/startup-orchestrator.zod` (`details` narrows + `Record` → `Record`). + - `PluginStartupResult` → `kernel/startup-orchestrator.zod`. FROM `plugin: +Plugin` (live object) and `error?: Error` TO the serializable projection + (`plugin: { name, version? }`-passthrough, `error?: { name, message, +stack?, code? }`). Neither side had any consumer outside spec; the + zod-validatable shape wins. + - `StartupOptions` → `kernel/startup-orchestrator.zod` — the PARSED tier + (defaults applied). `IStartupOrchestrator.orchestrateStartup` now takes + `StartupOptionsInput` (the caller-authored all-optional tier, also + re-exported from `./contracts`). Fix for callers typed to the old + all-optional `StartupOptions`: rename to `StartupOptionsInput`. + - `JobExecution` → `system/job.zod`. The system schema's `duration` field is + RENAMED `durationMs` — that is what every job adapter produces and what the + `sys_job_run.duration_ms` column round-trips; the schema described records + nothing ever wrote. Fix: `duration` → `durationMs` when parsing + `JobExecutionSchema` payloads. + - `AnalyticsQuery` → `data/analytics.zod`. The domain schema aligned to the + contract's semantics first: `timezone` LOST its `.default('UTC')` — absence + is meaningful (the engine resolves org timezone, #1982/#2018; the + `/analytics` entry always refused to apply that default). The schema is now + transform-free, so `AnalyticsQuery` ≡ `AnalyticsQueryInput` (both kept + exported). Fix for code that relied on `.parse()` injecting `timezone: +'UTC'`: pass the timezone explicitly or resolve it via the engine chain + (`selection.timezone ?? context.timezone ?? 'UTC'`). + + **Renamed — two genuinely different concepts were sharing one name (both + flow at runtime):** + + - `./contracts` `DriverCapabilities` → **`AnalyticsDriverCapabilities`** + (`{ nativeSql, objectqlAggregate, inMemory }`, the analytics strategy-chain + execution-path probe). The `DriverCapabilities` name now belongs solely to + the data domain's driver feature-flag record (`DriverCapabilitiesSchema`, + what `IDataDriver.supports` declares). Fix: importers of the trio from + `@objectstack/spec/contracts` (or `@objectstack/service-analytics`, whose + re-export is renamed in lockstep) rename the import; importers who meant + the driver flags import `DriverCapabilities` from `@objectstack/spec/data`. + + **Removed — the domain-side declaration was dead (zero import-level consumers + in framework/cloud/objectui; the #4411 family's last survivors):** + + - `system` `MetadataExportOptionsSchema` / `MetadataExportOptions` and + `MetadataImportOptionsSchema` / `MetadataImportOptions` (the + `output`/`source`-directory bags). The names now have ONE declaration each: + the `IMetadataService.exportMetadata` / `importMetadata` parameter + interfaces on `./contracts` (`types`/`namespaces`/`format` and + `conflictResolution`/`validate`/`dryRun`), which `MetadataManager` + implements. No tombstone/D2 conversion, deliberately — these are runtime + option-bag types, not authorable metadata (same reasoning as #4458). + `@objectstack/metadata` re-exports the two names from `./contracts` now + (it previously re-exported the dead system-side shapes its own manager + did not accept). + - `system` `JobSchedule` (the `= Schedule` back-compat alias). The name's one + declaration is the `IJobService.schedule` boundary shape on `./contracts` + (plain-string cron `expression`); the authored metadata type keeps its real + name `Schedule`. Fix: `import type { JobSchedule } from +'@objectstack/spec/system'` → `Schedule` (authoring tier) or the + `./contracts` `JobSchedule` (service boundary), whichever you meant. + +- 4b6cac7: feat(spec)!: resolve the three cross-form dual-source names — ShareRecipientType, TransformType, suggestFieldType (#4539) + + Three `dual-source-exports.baseline.json` rows where the two declarations + sharing a name did not even share a FORM (type vs const, or two unrelated + functions), so a wrong import-path pick had no shape overlap to hide behind + and failed far from the cause. Each judged against a three-repo import-level + scan (framework, cloud, objectui — the latter two contained zero references + to all three names). All three rows are deleted from the baseline. + + **Renamed — `./contracts` `ShareRecipientType` → `RecordShareRecipientType`:** + + Two live concepts shared the name. The security zod enum + (`user | team | position | unit_and_subordinates | business_unit`) is the + authorable sharing-RULE recipient vocabulary and keeps the name. The contracts + type describes a different thing — the `recipient_type` a `sys_record_share` + ROW may carry — and its claim to "mirror spec/security" had been false since + `group`→`team`/`guest` were retired there. Its member set is now aligned to + the storage-side gate it actually mirrors, the `SysRecordShare` + `recipient_type` select: `role` (never persistable, zero producers) is + replaced by `position`. Only `user` is enforced (and written) today; + `ISharingService.grant` keeps refusing every other value (ADR-0078). + Fix: `import type { ShareRecipientType } from '@objectstack/spec/contracts'` + (or from `@objectstack/plugin-sharing`, whose re-export is renamed in + lockstep) → `RecordShareRecipientType`; code that named the `'role'` member + was describing a value no row could ever hold — use the rule vocabulary + (`SharingRuleRecipientType`) if a role recipient was meant. + + **Renamed — `./shared` `TransformTypeSchema` / `TransformType` → + `FieldMappingTransformSchema` / `FieldMappingTransform`:** + + `./data`'s `TransformType` (the authorable import-mapping enum + `none | constant | lookup | split | join | javascript | map`) is the live + declaration and keeps the name. `./shared` exported `TransformType` as the + inferred type of `TransformTypeSchema` — a differently-shaped discriminated + union of transform CONFIG objects — with zero importers for either name in + all three repos. The shared pair is renamed (not just the alias deleted): + the docs generator derives `import type { X }` examples by stripping + `Schema` from each schema const, so an alias-less `TransformTypeSchema` + would have kept generating a reference to an export that no longer exists. + Fix: `TransformTypeSchema` → `FieldMappingTransformSchema`, + `import type { TransformType } from '@objectstack/spec/shared'` → + `FieldMappingTransform` (same shape); importers who meant the import-mapping + enum import `TransformType` from `@objectstack/spec/data`. + + **Renamed — `./data` `suggestFieldType` → `suggestFieldTypeForSqlType`:** + + The only function-kind dual-source. The two implementations were never forks + of one function — different signatures, semantics and return types: + `shared/suggestions.zod.ts` (kept on `.` / `./shared` under the original + name) is the typo-suggester for an invalid authored FieldType + (`(input: string) => string[]`, alias table + Levenshtein, feeds the zod + error map), while `data/type-compat.ts` is the deterministic SQL-column → + FieldType mapper for external-datasource drafts + (`(rawType, dialect?) => FieldType | undefined`, ADR-0015 §4.6). Same input, + divergent outputs — `('varchar(255)')` → `[]` vs `'text'`; `('text_area')` → + `['textarea']` vs `undefined`; `('int')` → `['number']` vs `'number'` — and + the wrong pick compiled wherever the result was only truthiness-checked + (`[]` is truthy). Behavioral divergence is now pinned in + `data/type-compat.test.ts`. + Fix: `import { suggestFieldType } from '@objectstack/spec/data'` → + `suggestFieldTypeForSqlType` (same signature); imports from the root entry + or `./shared` are unaffected. + +- 9f601e8: BREAKING(spec): `EnvironmentArtifact` 信封收敛为单一声明 —— `@objectstack/spec/system` 持有活的 wire 形状,`@objectstack/spec/cloud` re-export;从未实现的 v0 家族(`functions` / `manifest` / `payloadRef` 及其 8 个子 schema)退役 (#4740, #4535 C10) + + `EnvironmentArtifact` / `EnvironmentArtifactInput` / `EnvironmentArtifactSchema` 过去被两个入口导出,但**不是同一个声明**,拿到哪个形状只取决于 import 路径 —— #4411 陷阱: + + | 入口 | 声明位置(旧) | 形状 | 状态 | + | :------------------------- | :----------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | + | `@objectstack/spec/cloud` | `cloud/environment-artifact.zod.ts` | **活的 wire 形**:`checksum` 为 64 位 hex **字符串**,`metadata` = `ObjectStackDefinitionSchema` | **活**:全仓唯一 runtime Zod parse(`packages/metadata/src/plugin.ts` `_parseAndRegisterArtifact`)与 cloud 仓全部 type import 都用这侧 | + | `@objectstack/spec/system` | `system/environment-artifact.zod.ts` | 文档化「v0」:`checksum` 为 `{ algorithm, value }` **对象**、分类袋 `metadata`、内联 `functions[]`、必填 `manifest`、保留位 `payloadRef` | **declared-only**:三仓(objectstack / cloud / objectui)零代码消费者,从未有任何 producer 产出过该形状 | + + 两侧互相解析不过(checksum 类型硬冲突)。按维护者裁决(#4740,路线 A′):**单一声明落 `./system`、取活的 wire 形状,`./cloud` 改为 re-export 同一声明** —— 两个入口现在解析到同一批符号,对活消费者零迁移。 + + ## FROM → TO + + ```ts + // 不变 —— cloud 入口的名字与形状都没变,只是声明搬家(re-export) + import { + EnvironmentArtifactSchema, + type EnvironmentArtifact, + } from "@objectstack/spec/cloud"; + + // 不变(形状变了!)—— system 入口同名导出仍在,但现在是活的 wire 形: + // checksum: string(64 hex) 而非 { algorithm, value } 对象 + // metadata: ObjectStackDefinition 而非分类袋 + import { + EnvironmentArtifactSchema, + type EnvironmentArtifact, + } from "@objectstack/spec/system"; + ``` + + ```ts + // FROM —— 编译期起将以 TS2305 失败(实测三仓零命中,预期无人受影响) + import { + EnvironmentArtifactChecksumSchema, + EnvironmentArtifactFunctionSchema, + EnvironmentArtifactManifestSchema, + EnvironmentArtifactMetadataSchema, + EnvironmentArtifactPayloadRefSchema, + EnvironmentArtifactRequirementSchema, + EnvironmentArtifactHashAlgorithmEnum, + EnvironmentArtifactFunctionLanguageEnum, + } from "@objectstack/spec/system"; + // TO —— 无替代物:v0 家族从未被任何 producer/consumer 实现。 + // 校验 checksum 用 Sha256DigestSchema(现同时从 ./system 与 ./cloud 导出); + // 校验 metadata 用 ObjectStackDefinitionSchema(根入口)。 + ``` + + ## ⚠️ wire 形状警示(#4666 盲区:key 级门禁不可见的类型变更) + + - `EnvironmentArtifact['checksum']`:对 `./system` 侧 import 者是 **`{ algorithm, value }` 对象 → 64 位 hex 字符串** 的类型变更(`./cloud` 侧一直是字符串,不变)。线上 wire 从来只有字符串形;pin 测试钉住「旧对象形 → 拒;hex 字符串 → 过」。 + - `EnvironmentArtifact['metadata']`:对 `./system` 侧 import 者从宽松分类袋(passthrough)变为受 `ObjectStackDefinitionSchema` 校验的编译产物。 + - 退役键 `functions` / `manifest` / `payloadRef` 走 `retiredKey()` tombstone:作者写入即得升级指引(tsc 处 `never`,parse 处 prescription),不是静默剥离。 + + ## 退役论证(#4734 先例逐条评估,不照抄) + + **无 ADR-0087 D2/D3 conversion**:信封是**传输形状**,不是 authorable 元数据 —— 不作为 `sys_metadata` 行落库、不在 conversion walker 走的 stack 树上;且 `objectstack compile` 与控制面从未产出过这三个键(cloud 仓 `cloud-artifact-helpers.ts` 一直写 hex 字符串 checksum;函数代码走独立 runtimeModule,从不内联)。存量行**不可能携带**,conversion 写不出能跑到的 fixture(`converge-activation-event-schema` 同款论证)。tombstone prescription 即迁移文档。 + + ## 定级理由(逐条自证) + + 定 **major**:`./system` 的 16 个已发布导出名(8 schema const + 8 type)消失,外部 import 将以 TS2305 失败 —— 与 C14 / C16 同形,但**实测三仓 import 级零命中**。同时: + + - **零元数据迁移**:被删 9 个 def(`system/EnvironmentArtifact` 全家)均不从 `BUILTIN_METADATA_TYPE_SCHEMAS` 元数据根可达,#4650 门禁对 `authorable-surface.json` 被删 46 行的实跑判定是 7 组「def no longer emitted by this build」自证路径(输出见 PR);无 conversion / migration,`spec-changes.json` / upgrade-guide 零变化。 + - **runtime 零行为变化**:`packages/metadata/src/plugin.ts` 的 parse 目标形状就是收敛后的形状,一字未动。 + - cloud 仓 3 处 type import(`artifact-api-client.ts` / `file-artifact-api-client.ts` / `registry-reader.ts`,均 `spec/cloud`)名字与形状均不变。 + - JSON Schema 产物:`system/EnvironmentArtifact*` 9 个 def 停止发布(`json-schema.manifest.json` 同步删键,#2978 蓄意移除通道);新增 `system/Sha256Digest`(声明随家搬迁,`./cloud` 仍导出)。 + + ## 基线 6 → 3 + + `dual-source-exports.baseline.json` 删掉 `EnvironmentArtifact` / `EnvironmentArtifactInput` / `EnvironmentArtifactSchema` 三行,其余行一字未动。 + +- 51c5227: BREAKING(spec): `@objectstack/spec/automation` 不再导出 `EventSchema` —— 该名字在本包内曾指向**两个键集完全不相交的声明**,automation 侧是孤儿,已删除;`EventSchema` 现在全包唯一地指 `@objectstack/spec/kernel` 的事件总线信封 (#4658, #4535 C6) + + `EventSchema` 过去被两个入口导出,但**不是同一个声明**,拿到哪个只取决于 import 路径 —— #4411 陷阱。且两侧不是宽窄之差,是**两个概念**,键集零重叠: + + | 入口 | 声明位置 | 形状 | 概念 | + | :-------------------------------------------- | :-------------------------------- | :--------------------------------- | :------------------------------------------------ | + | `@objectstack/spec/automation`(**本次删除**) | `automation/state-machine.zod.ts` | `{ type, schema? }` | XState 式**信号声明**(「这台状态机接受哪些事件」) | + | `@objectstack/spec/kernel`(**不变,唯一真源**) | `kernel/events/core.zod.ts` | `{ id?, name, payload, metadata }` | 事件总线**信封**(一条已发出的事件实例) | + + automation 侧是**孤儿声明**:`StateMachineSchema` 从不引用它(状态机的事件类型是 `on:` 的**记录键**,纯字符串),`packages/spec` 内外零消费者(objectstack / cloud / objectui 三仓 import 语句级实测均为零)。收敛会把「状态机信号声明」写成「事件总线信封」—— 在合同里写假话 —— 故按维护者裁决(#4658,路线 A)删除孤儿而非收敛。 + + ## FROM → TO + + ```ts + // FROM —— 编译期起将以 TS2305 失败(实测三仓零命中,预期无人受影响) + import { EventSchema } from "@objectstack/spec/automation"; + ``` + + - 若你想要的是**事件信封**(校验一条已发出的事件): + + ```ts + // TO + import { EventSchema } from "@objectstack/spec/kernel"; + ``` + + - 若你想要的是**事件类型的声明**(名字、版本、payload 的 JSON Schema): + + ```ts + // TO —— kernel 侧本来就有的「事件定义」概念 + import { EventTypeDefinitionSchema } from "@objectstack/spec/kernel"; + ``` + + - 若你想给**状态机**声明它接受的事件:该表面从来不存在 —— 事件类型写在状态节点 `on:` 的记录键上(`on: { APPROVE: 'approved' }`),被删的 schema 从未接入 `StateMachineSchema`,没有替代物也不需要替代物。 + + ## 定级理由(逐条自证,未照抄前例) + + 定 **major**,因为这是一次**已发布导出名的移除**:外部 `import { EventSchema } from '@objectstack/spec/automation'` 会以 TS2305 编译失败(与 C14 同形)。 + + 同时它是**零元数据迁移**: + + - `automation/Event` def 从 `BUILTIN_METADATA_TYPE_SCHEMAS` 元数据根(24 型)**不可达** —— 以 #4650 门禁同款真 Zod 图 BFS 对合并基线实测复核(输出见 PR;同一次 BFS 里 `StateMachineSchema` 本身可达,证明删除是外科式的)。没有任何元数据文档曾被它解析,`authorable-surface.json` 里对应两行(`automation/Event:type` / `automation/Event:schema`)是过度收集的产物,随整 def 出账(#4650 门禁在本 PR 打印的判定是「def no longer emitted」自证路径),**无 tombstone、无 ADR-0087 conversion / migration**。 + - 已存 `sys_metadata` 数据、运行时校验行为全部不受影响;`kernel/events/core.zod.ts` 的 `EventSchema` 一字未动。 + - JSON Schema 产物中 `automation/Event` 停止发布(`json-schema.manifest.json` 同步删键,#2978 蓄意移除通道)。 + + ## 基线 13 → 12 + + `dual-source-exports.baseline.json` 删掉 `EventSchema — [./automation (const)] ≠ [./kernel (const)]` 一行,其余 12 行一字未动。 + +- a4a85c8: BREAKING(spec): `FieldMapping` named three declarations — the two domain-specific + sides are renamed to `ConnectorFieldMapping` and `ImportFieldMapping` (#4703, #4535 C12) + + `FieldMapping` / `FieldMappingSchema` were exported by **three** entry points for + **three different declarations**, so which type you got depended only on the import + path — the #4411 trap, one entry worse than the usual pair: + + | entry | declaration | keys | shape | + | :-------------------------------------------- | :----------------------------- | :--- | :---------------------------------------------- | + | `@objectstack/spec/shared` (**unchanged**) | `shared/mapping.zod.ts` | 4 | the base — plain `z.object` | + | `@objectstack/spec/integration` (**renamed**) | `integration/connector.zod.ts` | 7 | `Base.extend({ dataType, required, syncMode })` | + | `@objectstack/spec/data` (**renamed**) | `data/mapping.zod.ts` | 4 | an independent `strictObject` | + + The first two are base-and-superset. The third is **not the same concept at all**: it + is the column mapping of a CSV/table import (`mapping.fieldMapping[]`), not a + connector's remote-field mapping. Three ways the two are mutually unparseable: + + 1. **`transform` is the same key name with incompatible value types.** `shared` / + `integration` take the discriminated union `FieldMappingTransformSchema` + (`{ type: 'cast', targetType: 'string' }`); `data` takes a flat `TransformType` + enum defaulting to `'none'`, steering a separate `params` bag. + 2. **Different cardinality.** `data` accepts `string | string[]` for `source` and + `target` — one target field may be composed from several columns (`split` / + `join`). The other two accept a single `string`. + 3. **Opposite failure modes for an unknown key.** `data` is a `strictObject` + (#4001): it **throws**, naming the canonical spelling. The other two are plain + `z.object`: they **strip silently**. Under one shared name, the same typo is a + hard error in one domain and a no-op in the other. + + Per **ADR-0112 D9(a)** the domain-specific sides take a domain prefix and the base + keeps the bare name — the same ruling that produced `ConnectorRateLimitConfig` + (#4684), `ConnectorErrorCategory` and `ConnectorRetryStrategy`. This is not a new + convention: `data/ExternalFieldMappingSchema` already extends the same base and, + purely because it carries a prefix, never entered the dual-source baseline at all. + + The dual-source baseline shrinks **16 → 14**. + + ## FROM → TO + + ```ts + // before — @objectstack/spec/integration + import { + FieldMappingSchema, + type FieldMapping, + } from "@objectstack/spec/integration"; + // after + import { + ConnectorFieldMappingSchema, + type ConnectorFieldMapping, + } from "@objectstack/spec/integration"; + + // before — @objectstack/spec/data + import { + FieldMappingSchema, + type FieldMapping, + } from "@objectstack/spec/data"; + // after + import { + ImportFieldMappingSchema, + type ImportFieldMapping, + } from "@objectstack/spec/data"; + ``` + + **Importing from `@objectstack/spec/shared`? Nothing changes** — that `FieldMapping` + is the base, keeps its name, its four keys and its plain-`z.object` behaviour. + + No deprecated aliases are kept on either renamed entry: re-exporting the old name + would be a third declaration of it and would re-open the trap this change closes. + + ⚠️ **Do not "fix" the compile error by re-pointing the import at + `@objectstack/spec/shared`.** That name resolves, and it is the wrong schema. On the + connector side it silently costs you `dataType` / `required` / `syncMode` — the base + is not `.strict()`, so those keys are **stripped at parse time** and the mapping runs + without them. On the import side the base rejects arrays and the enum form of + `transform` outright. Take the prefixed name for the domain you are in. + + ## Authored metadata needs no migration + + This renames TypeScript exports and two internal JSON Schema `$def`s — **not a single + authorable key**. All eleven keys carry over unchanged, verified by the + `authorable-surface.json` ratchet rather than by inspection: + + - `connectors[].fieldMappings[]` — `source`, `target`, `transform`, `defaultValue`, + `dataType`, `required`, `syncMode` (7) + - `mapping.fieldMapping[]` — `source`, `target`, `transform`, `params` (4) + + Same names, same types, same defaults, same strictness. Existing stack metadata, + stored `sys_metadata` rows and published apps are byte-for-byte unaffected, which is + why this ships with **no ADR-0087 conversion and no tombstone**: nothing was retired. + The `major` is for the two renamed TypeScript exports alone — the only edit an upgrade + needs is the import above. + + The published JSON Schema `$id`s move with the defs: + `…/integration/FieldMapping.json` → `…/integration/ConnectorFieldMapping.json`, and + `…/data/FieldMapping.json` → `…/data/ImportFieldMapping.json`. + + ## Gate change riding along + + `scripts/lib/renamed-defs.ts` (the #4684 carry-over table) gets its first entries + beyond the original one, and with them the first rules that only bind when the table + holds **more than one**: + + - **two sources onto one target is rejected.** That is a merge, not two renames, and + it defeats the table's purpose: `build-schemas.ts` carries the snapshot into a map + keyed by the _new_ key, so two defs' entries for one property name collapse — and + the surviving `[RETIRED]` state is whichever was carried last. A key live under one + def and tombstoned under the other would then read as already-retired, and the + "every live → retired transition needs a registered conversion" check would never + fire for it. + - **a chained rename (A → B → C) is rejected by name.** It was already red as + "B is not emitted", which is true but misdiagnoses it as a typo; the carry is a + single pass, so chains are unsupported outright. + +- 0e96e46: refactor(spec,cli,runtime)!: 退役 `crypto.hash` 能力 —— 声明了四层、构建期还自动推断,沙箱从没实现(#4391,ADR-0049 enforce-or-remove) + + `crypto.hash` 是四层声明、零层实现:`HookBodyCapability` 枚举收它、枚举旁的文档表列它、CLI 提取器**自动推断**它、`ScriptContext.crypto.hash` 还写了签名 —— 而 `installCtx` 只往 VM 的 `ctx.crypto` 上装了 `randomUUID`。于是这个 token 唯一授权的那次调用,**每一次都在 VM 里抛**。 + + 这比普通的 declared ≠ enforced 更毒一档,坏就坏在**构建期推断**:作者(尤其是 AI 作者)写下 `ctx.crypto.hash(...)`,提取器就替他把能力加进 `capabilities`,`os build` 因此全绿 —— 系统亲手把人送进一条必炸的死路,而唯一诚实的记录是文档表格里一句 `_(not yet wired)_`,没有作者会先读表格再写 body。 + + **裁决是 remove,不是实现**(维护者 2026-08-02):从未实现、调用即抛、**零投诉** —— 对一个每次使用都抛错的能力来说,这本身就是最强的活性证据,没人需要它。在沙箱里实现 crypto 会扩大沙箱的能力面与安全审查面,那是长期成本而非一次性工时,无业务拉动不做。真需要哈希时按能力准入流程重提:**实现先行,声明随实现走**(ADR-0049 的 enforce 腿留给有实现的那天)。 + + ## FROM → TO + + | 写了什么 | 现在怎么办 | + | :---------------------------------- | :------------------------------------------------------------------------------------ | + | `capabilities: ['crypto.hash']` | **删掉这个 token**。它从未授权成任何东西 | + | `await ctx.crypto.hash(algo, data)` | **删掉这次调用**。它从未返回过值 —— 今天能跑的代码没有一行依赖它 | + | 确实需要哈希 | 在 host 侧做(Connector recipe,或引擎侧 hook)。沙箱内哈希须走能力准入流程重开,实现先行 | + + 一句话修法:**两个都删**。`os migrate meta --from 16` 会自动帮你剥掉 token;那行**死调用是你自己要删的** —— 转换层刻意不改 body 源码(见下)。 + + ## 定级理由(逐条自证,未照抄前例) + + 三问按 #4535 §5 逐条走: + + 1. **会不会 TS2305 / TS2339?** 会,两处。`HookBodyCapability` 是 public 导出类型,把它当**字面量联合**用的代码(`const c: HookBodyCapability = 'crypto.hash'`、对 token 做穷举 switch)现在编译失败;`ScriptContext.crypto.hash` 的调用点以 TS2339 失败。实测三仓(objectstack / cloud / objectui)裸名扫描 `crypto.hash` / `ctx.crypto.hash` / `'crypto.hash'` —— **两个兄弟仓零命中**,本仓命中全在本 PR 内清理。 + 2. **有没有元数据迁移?** 有。token 是写在作者源 hook/action body `capabilities: []` 数组里的**值**,也会躺在已存的 `sys_metadata` 行里 —— 故注册了 ADR-0087 D2 转换 `hook-body-crypto-hash-removed`(D3 挂 protocol-17)。这是与 #4767 / #4783 / #4616 的分界:那三单退役的是**导出名 / 运行时描述符**,没有作者源可改写;本单有,和 `object-enable-trash-mru-removed` / #4734 同侧。 + 3. **形状变更?** 是**枚举值收窄**(6 → 5),不是 key 移除。故**没有 `retiredKey()` 墓碑** —— `capabilities` 这个 key 本身依然活着、依然被强制。处方改由枚举自己的 error map 承载,并按 `object.managedBy: 'system'` 的先例**以 `issue.input` 为键**:只有「曾经合法」的那个拼写会被告知「was removed」,写错成 `crypto.hsah` 的作者拿到的仍是 zod 自己那条列出合法 token 的消息 —— 告诉他「你的值被退役了」属于误导。 + + `@objectstack/cli` 与 `@objectstack/runtime` 同定 **major**:前者 `ExtractedBody.capabilities` 的公开联合类型收窄(赋值给它的代码 TS2322),后者 `ScriptContext.crypto` 少一个成员(TS2339)。 + + ## 门禁实报 + + 枚举值收窄对四张 ratchet **全部不可见**,这一点值得单独记一笔:`authorable-surface.json` 记到 key 级(`data/ScriptBody:capabilities`),`json-schema.manifest.json` 记 def 名(`data/HookBodyCapability` 仍在),`packages/spec/json-schema/` 本身 gitignore。所以 `check:authorable-surface` / `check:api-surface` 实跑**零变化**,`check:liveness` / `check:empty-state` 同样 PASS(`capabilities` key 仍活,不产生台账行变更)。 + + 也就是说:**本次移除没有任何一张基线能自动兜住它** —— 兜住它的只有本 PR 新增的 pin 测试(spec / cli / runtime 各一组,已 sabotage 实跑验证复活即红)。`check:generated` 8/8 绿,移动的是 `spec-changes.json`、`docs/protocol-upgrade-guide.md` 与两页生成参考文档(`data/hook-body.mdx`、`ui/action.mdx`,枚举选项随之少一项)。 + + ## 转换刻意不做的事 + + `hook-body-crypto-hash-removed` 只从 `body.capabilities` 里剥掉死 token,**不碰** body 源码里那行 `ctx.crypto.hash(...)`。这是有意的:那行调用从未返回过值,剥掉授权不会让任何还能跑的东西变坏;但把它一并「修好」会让作者失去唯一一个还在提醒他「这里有段死代码」的信号。`retiredFromLoadPath: true` —— 枚举当场拒绝,活作者在 parse 时就被教育,转换存在的意义是让已存的 16.x / 17-rc 行重放干净(否则永远被打成 `metadata_spec_invalid`,把链上历史误标成当期违约)以及让 `os migrate meta --from 16` 改写作者源。 + +- d52d4fe: BREAKING(spec): `@objectstack/spec/ui` 不再导出 `HttpMethod` —— 该名字在本包内指向**两个不同的类型**,`./ui` 那一个改名为 `HttpMethodType` (#4691, #4535 C14) + + `HttpMethod` 过去被三个入口导出,但**不是同一个声明**,拿到哪个只取决于 import 路径 —— #4411 陷阱。而且与 C11(`HttpRequest`)不同,这一簇两侧连**取值集合都不一样**: + + | 入口 | 声明位置 | 取值 | + | :------------------------------------------------------------ | :------------------------------------------------------- | :------------------------------------------------------------------ | + | `@objectstack/spec/shared`、`@objectstack/spec/api`(**不变**) | `shared/http.zod.ts` 的 `z.enum([...])` | **7 值** — `GET` `POST` `PUT` `DELETE` `PATCH` **`HEAD` `OPTIONS`** | + | `@objectstack/spec/ui`(**本次移除**) | `ui/view.zod.ts` 的 `z.infer< typeof HttpMethodSchema >` | **5 值** — `GET` `POST` `PUT` `PATCH` `DELETE` | + + 7 值那个描述的是「HTTP 协议本身有哪些方法」(CORS `methods[]`、REST 路由表、endpoint 声明都用它);5 值那个是 UI/View 数据源允许配置的**真子集**,它的注释自己写着 _"HTTP Method Schema (subset for UI/View data sources)"_。 + + ## FROM → TO + + ```ts + // FROM —— 拿到的是 5 值的 UI 子集类型 + import type { HttpMethod } from "@objectstack/spec/ui"; + + // TO —— 同一个类型,同一个入口,零形状变化 + import type { HttpMethodType } from "@objectstack/spec/ui"; + ``` + + `HttpMethodType` 是 `shared/http.zod.ts` 里 `z.infer< typeof HttpMethodSchema >` 的既有名字(`@objectstack/spec/shared` 一直在导出),本次由 `./ui` **re-export** 同一个声明,所以改完之后解析到的类型与改之前逐字相同。 + + ⚠️ **不要把 import 路径改成 `@objectstack/spec/shared` 而保留 `HttpMethod` 这个名字。** 那里的 `HttpMethod` 是**7 值**的那一个,会把类型悄悄放宽两个值,而 `HttpRequestSchema.method` 运行时只接受 5 值 —— `method: 'HEAD'` 会通过编译、在 `.parse()` 抛错。之所以把 `HttpMethodType` 也从 `./ui` re-export 出去,就是为了让编译器的 "did you mean" 指向同一入口里正确的那个名字,而不是引诱这次换路径。 + + 用 7 值枚举的代码不受影响:`import { HttpMethod } from '@objectstack/spec/api'`(或 `/shared`)行为一字未变。 + + ## 为什么不是「让 `./ui` re-export `./shared` 的 `HttpMethod`」 + + C11(#4688)对 `HttpRequest` 用的正是这一招,当时是对的 —— 两侧 `z.infer` 的是**同一个 schema 对象**,形状逐字段相同,收敛后消费者零感知。 + + 本簇不成立:那样做会把 `./ui` 的 `HttpMethod` 从 5 值放宽到 7 值,而 `HttpRequestSchema.method`(`shared/http.zod.ts`)校验用的仍是 5 值的 `HttpMethodSchema`。结果是**类型开始对运行时说谎** —— 少一行基线,换来一个编译期放行、运行期抛错的坑。所以走的是「`./ui` 不再叫这个名字」,让 5 值类型保留它在 `./shared` 里已有的诚实名字。 + + `HttpMethodSchema` 的值域**一字未动**(仍是 5 值),`HttpRequestSchema` 的运行时行为**零变化**。 + + ## 定级理由(逐条自证,未照抄前例) + + 定 **major**,因为这是一次**已发布导出名的移除**:外部 `import type { HttpMethod } from '@objectstack/spec/ui'` 会以 TS2305 编译失败。这与 C11 定 patch 的情形正相反 —— 那次名字仍在导出、只是换了声明来源,消费者无需改一个字符;本次名字没了。 + + 同时它**不是元数据破坏**: + + - `authorable-surface.json` **零变化**(实跑 `check:authorable-surface` ✓)—— `HttpMethod` 是纯 TS 类型别名,不是可作者化的 key。 + - 因此**无 tombstone、无 ADR-0087 conversion / migration**,`spec-changes.json` 与 `protocol-upgrade-guide.md` 零变化(两个 gate 均 ✓)。 + - 已存 `sys_metadata` 数据、JSON Schema 产物、运行时校验行为全部不受影响。 + + 也就是说:**零元数据迁移,只有一处一行的 TypeScript import 改动。** + + ## 基线 14 → 13 + + `dual-source-exports.baseline.json` 删掉 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 一行,其余 13 行一字未动。这是 #4535 v17 双源账的**最后一条**。 + + `api-surface.json` 的改动恰好只有 `./ui` 的一行对换:`- HttpMethod (type)` / `+ HttpMethodType (type)`。 + +- ce92674: feat(spec)!: `job` is a code artifact — runtime creation and org overrides are withdrawn (#4509) + + A `job` metadata item created at runtime could never be scheduled. `JobSchema.handler` + names a function in the **compiled bundle's function table** — the schema says so + ("must match a key in `defineStack({ functions })`") and the scheduler is built that + way: `AppPlugin` sources jobs from `bundle.jobs` alone and resolves each handler + through `collectBundleFunctions(bundle)`, skipping any job whose handler is not in + that table. Yet the type was registered `allowRuntimeCreate: true` (and + `allowOrgOverride: true`), so a job authored in Studio or through `PUT /meta` parsed, + saved, reported success — and never ran. + + Unlike the sibling disconnects closed in this batch, this one **cannot be bridged**. + The runtime writer does not have the bundle and cannot name a function inside it; the + missing piece is a handler-binding design, not an ingestion path. Under ADR-0049 + enforce-or-remove, the honest move is to close the door: + + - `allowRuntimeCreate: false` — no "create job" in Studio or via `PUT /meta`. + - `allowOrgOverride: false` — no per-org job fork, which was unreachable for the same + reason. + + **`job` remains a first-class authorable type.** `*.job.ts` / `*.job.yml` / + `*.job.json` files and `defineStack({ jobs })` are the supported doors, and they are + fully enforced — every schedule shape, `retryPolicy`, `timeout` and `enabled` reach + the scheduler. The kind stays in the metadata registry because its file loader is + genuinely consumed (ADR-0088 admission test). + + **If you were creating jobs at runtime:** move the definition into your stack + (`defineStack({ jobs, functions })`) so the handler resolves against a real function. + Rows already in `sys_metadata` are left untouched — they were never scheduled, so + nothing changes behaviorally; `migrateStoredMetadata` now reports them `skipped`, the + same way it does for `agent`. + + Re-opening the type means constraining `handler` to something a runtime writer can + name — an already-registered flow, or a named and separately governed function — and + building the bridge to `IJobService.schedule`. Flipping the flag without that work + just restores the silent no-op. + +- cf2c9b7: refactor(spec)!: remove the `kernel` metadata-loader envelope family — eleven names that each existed twice, with different shapes, on two subpath entries (#4411) + + `MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, + `MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, + `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo` and + `MetadataLoaderContract` (plus each one's `…Schema`) are removed from + `@objectstack/spec/kernel` (`kernel/metadata-loader.zod`). Every one of those + names _also_ existed, with a **different shape**, in + `@objectstack/spec/system` (`system/metadata-persistence.zod`). + + Which type you got depended on nothing but your import path: + + ```ts + import type { MetadataWatchEvent } from "@objectstack/spec/kernel"; // one shape + import type { MetadataWatchEvent } from "@objectstack/spec/system"; // another + ``` + + - **The `kernel` copies had zero consumers.** Import-statement scans across this + repo, `cloud` and `objectui` found every consumer importing from + `./system` (or, for the export/import options, `./contracts`' own interface). + Nothing but `kernel/metadata-loader.test.ts` ever parsed the `kernel` copies. + - **The naming intuition pointed the wrong way**, which is what made this worse + than an ordinary duplicate. The `kernel` copies were the ones that _looked_ + canonical — normalized enums, required fields, a `.describe()` on every + property — and they were the dead ones. The live copy is the loose superset, + and `metadata-manager.ts` calls it "legacy" in its own comments. An + auto-import or a model completion picking by name, or by which one reads as + more rigorous, picked the dead one; because the shapes overlap heavily, that + choice compiled and only failed later, at an edge value (`add` vs `added`) or + on a field one copy made required. + - **No load path parsed them.** These are runtime envelope types, not authorable + metadata — no authored source can carry them. So there is deliberately **no** + `retiredKey()` tombstone and **no** ADR-0087 conversion: a prescription nobody + can receive is noise, and there is nothing for `os migrate meta` to rewrite + (the `plugin-runtime.zod.ts` / dev-plugin precedents, #3950, #4149). + + **FROM → TO — change the import path, keep the name:** + + ```diff + -import type { MetadataWatchEvent, MetadataStats } from '@objectstack/spec/kernel'; + +import type { MetadataWatchEvent, MetadataStats } from '@objectstack/spec/system'; + ``` + + The surviving `system` copy is the **looser** of the two, so a _reader_ of these + types may need narrowing it did not need before; a _producer_ needs nothing. The + differences that actually bite: + + | Type | `kernel` (removed) | `system` (keep) | + | ------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | + | `MetadataWatchEvent.type` | `'added' \| 'changed' \| 'deleted'` | also `'add' \| 'change' \| 'unlink'` — the raw watcher values the runtime really emits | + | `MetadataWatchEvent` | `metadataType` / `name` / `timestamp` required | all three optional; adds `stats` | + | `MetadataStats` | `size` / `modifiedAt` / `etag` / `format` required | all optional; adds `mtime`, `hash` | + | `MetadataFormat` | `json \| yaml \| typescript \| javascript` | also the `yml` / `ts` / `js` aliases | + | `MetadataSaveResult.path` | required | optional; adds `stats` | + | `MetadataImportOptions` | `conflictResolution` / `dryRun` / `continueOnError` / `transform` | `source` / `strategy` / `validate` | + | `MetadataCollectionInfo` | `formats: MetadataFormat[]` | `namespaces: string[]` | + + No runtime behaviour changes: nothing read the removed copies. The `system` + shapes are **not** tightened here — they describe what `MetadataManager` + actually emits, and narrowing them would be a separate behaviour change. + + `MetadataManagerConfig` and `MetadataFallbackStrategy` are **unaffected**. They + were never duplicated — `kernel` owns them and `system` re-exports them — and + that is the split that survives: manager _wiring_ is kernel's, the loader/watch + _envelope_ is system's, and nothing is declared twice. + + The retirement kit: baselines dropped deliberately + (`json-schema.manifest.json` minus the 11 `kernel/Metadata*` entries; + `authorable-surface.json` minus the 65 matching lines — nothing can author + these, so no `[RETIRED]` markers); `api-surface.json` regenerated (22 exports + leave `./kernel`); `references/kernel/metadata-persistence.mdx` removed by + `gen:docs`; v17 release notes' dead-clusters table and upgrade checklist + extended. No liveness-ledger entries existed (the ledger tracks authorable + metadata types; these were never one). + +- 5966c2a: feat(spec)!: retire the five keys the advisory lint could never have warned about — mapping `extractQuery`/`errorPolicy`/`batchSize`, contextSelector `includeAll`/`placement` (#4509) + + Five authorable keys parsed, stored, and controlled nothing. What groups them is + not the type they sit on but **why they had to go out in a major rather than + after a deprecation cycle**: four of the five carry schema DEFAULTS, and a + default materialises at parse time — so the liveness advisory lint cannot tell a + value the author wrote from one the schema supplied. Marking them would have + warned on every mapping and every selector in existence, which is why the ledger + recorded them as `_authorWarnSkipped` instead. For a key in that state, removal + is not the escalation after a warning. It is the only channel that ever reaches + the author. + + **The retirement kit:** + + | FROM | TO | Fix | + | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | + | `mapping.extractQuery` | _(removed)_ | Delete the key. Exports run through the ordinary query API (`POST /api/v1/data/:object/query`) — no exporter has ever read a mapping artifact. | + | `mapping.errorPolicy` | _(removed)_ | Delete the key. Error handling on the import path belongs to the import REQUEST's own options, not the stored mapping. | + | `mapping.batchSize` | _(removed)_ | Delete the key. The write path sizes its own batches. **Do not relocate the value** — see below. | + | `app.contextSelectors[].includeAll` | _(removed)_ | Delete the key. Selectors are mandatory-scope; widen `optionsSource.filter` to widen the choices. | + | `app.contextSelectors[].placement` | _(removed)_ | Delete the key. Selectors always render in the sidebar header; `'topbar'` placed nothing. | + + Run `os migrate meta --from 16` to rewrite existing sources automatically. + + **`includeAll` is the one worth reading twice.** It was not unread — it was + deliberately _disobeyed_, and for a security reason. A context selector is a + mandatory scope, so an "All" row would clear the scope on a surface that exists + to be scoped; on Studio's package selector that means listing the platform's own + system/cloud kernel packages to a developer who scoped to their own package. The + renderer never offered an All row regardless of the flag, so `includeAll: false` + hardened nothing and `includeAll: true` unlocked nothing. `STUDIO_APP` shipped + authoring `includeAll: true` against a renderer that ignored it — that authoring + site goes with the key in this change. + + **`batchSize` deliberately offers no rename.** `bulkActionDef.batchSize`, + `connector.batchSize`, `sync.batchSize`, `offline.batchSize`, the seed loader's + and the NoSQL driver cursor's are all LIVE and enforced — but each is a + different key on a different type sizing its own path, and none of them sizes a + mapping import. The rejection says so explicitly, because "removed" plus a + familiar name one line away is exactly how a dead setting gets laundered into a + live-looking one. Same trap `datasource.retryPolicy` had to defuse against + `hook`/`job` `retryPolicy` (which spell the delay `backoffMs`) one issue + earlier. + + Both schemas are `.strict()`, so the keys are deleted from the shape and + rejected with a `guidance` prescription rather than tombstoned; their liveness + rows are deleted rather than kept. The retired ALIAS spellings (`query`, + `onError`, `errorHandling`, `errorMode`, `batch`, `chunkSize`, `skipErrors`, + `showall`, `location`) route to the same prescriptions instead of suggesting a + rename onto a key that is also gone. + + Registered as the ADR-0087 D2 conversion `mapping-inert-keys-removed` and an + extension of `app-dead-authoring-keys-removed`, both wired into the protocol-17 + D3 chain step. The mapping conversion is scoped to the `mappings` collection + deliberately — a stack-wide strip would delete an enforced `batchSize` from + connector, sync, bulk-action and offline shapes. + + `datasource` reached zero dead keys in #4583; `mapping` reaches zero here. + +- a2cd18a: feat(spec)!: `@objectstack/spec/kernel` no longer exports `MetadataEvent(Schema)` / `MetadataBulkRegisterRequest(Schema)` — the bare names belong to `./api` alone (#4587) + + The names `MetadataEvent` / `MetadataEventSchema` / + `MetadataBulkRegisterRequestSchema` resolved to **two different declarations** + depending on the import path (`./api` vs `./kernel`) — the #4411 dual-source + trap. Resolution (three-repo, import-statement-level consumer scan: framework, + cloud, objectui — the `./kernel` copies had zero importers outside their own + unit test): + + - **Removed** `MetadataEventSchema` / `MetadataEvent` from + `@objectstack/spec/kernel`. This was a lifecycle-event envelope + (`event: 'metadata.registered' | … | 'metadata.exported'`, plus + `actor`/`payload`/`namespace`) that **nothing ever emitted or consumed** — + the vocabulary appears in no producer in any of the three repos. The live + contract is `./api`'s `MetadataEvent(Schema)` + (`type: 'metadata.{type}.{created|updated|deleted}'`, with `id` / `definition` + / `userId`): `MetadataManager` publishes those events to the realtime + service and `@objectstack/client` / `@objectstack/client-react` subscribe to + them. + - FROM `import { MetadataEvent } from '@objectstack/spec/kernel'` → + TO `import type { MetadataEvent } from '@objectstack/spec/api'`. + **Shape change**: the api event has `id` (uuid), `type` + (`metadata.{type}.{created|updated|deleted}`), `definition`, `userId`; the + removed kernel shape's `event` / `actor` / `payload` / `namespace` fields + do not exist there. If you needed runtime _watch_ events, that contract is + `MetadataWatchEvent` in `@objectstack/spec/system`; repository change-log + events are `MetadataEvent` from `@objectstack/metadata-core` (ADR-0008) — + a third, unrelated declaration that is not part of `@objectstack/spec`. + - **Removed** `MetadataBulkRegisterRequestSchema` / + `MetadataBulkRegisterRequest` from `@objectstack/spec/kernel`. It was a dead + near-duplicate of the REST contract that also diverged from the enforced + write path: its per-item `namespace` field exists neither in + `IMetadataService.bulkRegister` (contracts) nor in + `MetadataManager.bulkRegister`, and `namespace` is deprecated platform-wide. + - FROM `import { MetadataBulkRegisterRequestSchema, MetadataBulkRegisterRequest } from '@objectstack/spec/kernel'` → + TO `import { MetadataBulkRegisterRequestSchema, type MetadataBulkRegisterRequest } from '@objectstack/spec/api'` + (the `POST /api/meta/bulk/register` contract; the type export is new on + `./api` in this release). **Shape change**: items are strictly + `{ type, name, data }` — a per-item `namespace` no longer parses into the + accepted shape. `MetadataBulkRegisterRequest` is the authoring-side type + (`z.input`): `continueOnError` / `validate` stay optional and carry + defaults, as before. + - `@objectstack/spec/api`'s `MetadataEvent(Schema)` and + `MetadataBulkRegisterRequestSchema` are **unchanged** and are now the sole + owners of the bare names. Imports from `./api` need no migration. + - `@objectstack/spec/kernel`'s `MetadataBulkResultSchema` / + `MetadataBulkResult` are **unchanged** — only the bulk _register request_ + pair moved. + + `dual-source-exports.baseline.json` shrinks by exactly these 3 rows (31 → 28, + #4535 C2). + +- 4638aaa: `MetadataWatchEvent.type` now carries only the values the runtime emits: the enum narrows FROM `'add' | 'change' | 'unlink' | 'added' | 'changed' | 'deleted'` TO `'added' | 'changed' | 'deleted'` (#4536, follow-up to #4411). + + The three raw chokidar values had zero producers: both emit sites normalize before constructing the event — `packages/metadata/src/node-metadata-manager.ts` translates chokidar's `add`/`change`/`unlink` in the watcher callbacks (`handleFileEvent` accepts only the canonical three), and `packages/metadata/src/metadata-manager.ts` normalizes repository ops (`create`/`update`/`delete` → `added`/`changed`/`deleted`). Consumers parsing events therefore never received the raw values, and no runtime behavior changes. + + - FROM: an external implementor could construct events typed `'add'`/`'change'`/`'unlink'` and readers had to (needlessly) branch on six values. + - TO: an implementor constructing events with the raw values must emit `added`/`changed`/`deleted` instead; readers may delete any branches on `add`/`change`/`unlink` — they were unreachable. + + No tombstone / ADR-0087 conversion: this is a runtime event envelope type, not authorable metadata — nothing parses it on a load path (the #4411 route). + +- 0222d3c: feat(spec)!: converge the dual-source `MetadataFormat` and `CacheStrategy` enum declarations (#4537) + + Two enum vocabularies were declared twice, on `./shared` and `./system`, and had + diverged on their **values** — which type (and which accepted value set) you got + depended on nothing but the import path (the #4411 trap; #4535/#4506 baseline). + Both converge on one declaration each; the three + `dual-source-exports.baseline.json` rows are deleted. + + **`MetadataFormat` / `MetadataFormatSchema` — the shared declaration is the + single source.** `system/metadata-persistence.zod` no longer declares its own + 7-member copy; it re-exports `shared/metadata-types.zod` (the + `MetadataManagerConfig` pattern — `kernel/metadata-loader.zod` already imported + the shared one since #4411). Breaking on the `./system` entry only: the + extension-style aliases `'yml'`/`'ts'`/`'js'` are no longer accepted. They had + zero producers in this repo, objectui and cloud — every loader normalizes at the + boundary (`FilesystemLoader.detectFormat` maps `.yml` → `'yaml'`, `.ts` → + `'typescript'`, `.js` → `'javascript'`; the database/remote/memory loaders + always emit `'json'`). Migration: write the canonical name — + `'yml'` → `'yaml'`, `'ts'` → `'typescript'`, `'js'` → `'javascript'`. + + **`CacheStrategy` — `system/cache.zod` (`CacheStrategySchema`) is the single + declaration.** The `./shared` copy `CacheStrategyEnum` (and its `CacheStrategy` + type export) is removed: it had zero importers in all three repos, while the + system schema is the one `CacheTier.strategy` gates on — same disposition as + `AggregationFunctionEnum` (objectui#2945): removed rather than reconciled. + Migration: `import { CacheStrategySchema, type CacheStrategy } from +'@objectstack/spec/system'`. The value `'adaptive'`, declared only on the + system side with zero producers, is dropped — the enum carries the four values + both declarations agreed on (`'lru' | 'lfu' | 'fifo' | 'ttl'`); pick one of + those. + + No ADR-0087 conversion / tombstone: loader envelope + config vocabulary with no + authorable-metadata producers (the #4411 / #4536 route), verified by three-repo + scan on the issue. + +- 0a936ea: feat(spec)!: the notification vocabulary has one owner per name — `@objectstack/spec/ui` no longer exports `Notification(Schema)` / `NotificationConfig(Schema)`, and `@objectstack/spec/system` no longer exports `NotificationConfig(Schema)` (#4610) + + The names `Notification` / `NotificationSchema` (`./api` vs `./ui`) and + `NotificationConfig` / `NotificationConfigSchema` (`./system` vs `./ui`) + each resolved to **two different declarations** depending on the import + path — the #4411 dual-source trap. Resolution (three-repo, + import-statement-level consumer scan: framework, cloud, objectui): + + - **Removed** `NotificationSchema` / `Notification` from + `@objectstack/spec/ui`. This was a toast/banner "notification instance" + shape (`type`/`severity`/`message`/`duration`/`actions`/`position` + ARIA + props) with **zero importers** in all three repos — objectui's toaster + never adopted it. The live contract is `./api`'s `Notification(Schema)`: + the REST inbox row (`id`/`type`/`title`/`body`/`read`/`data`/`actionUrl`/ + `createdAt`) embedded in `ListNotificationsResponseSchema`, served by + `/api/v1/notifications`, implemented by `@objectstack/client`, and + mirrored by `InboxNotification` in `@objectstack/spec/contracts` + (ADR-0030: the bell reads this shape). + - FROM `import { NotificationSchema, type Notification } from '@objectstack/spec/ui'` → + TO `import { NotificationSchema, type Notification } from '@objectstack/spec/api'`. + **Shape change**: the api row is an inbox record, not a presentation + config — the ui shape's `severity` / `duration` / `dismissible` / + `actions` / `position` / ARIA fields do not exist there. For the + presentation vocabulary keep using the ui enums, which are unchanged: + `NotificationTypeSchema`, `NotificationSeveritySchema`, + `NotificationPositionSchema`, `NotificationActionSchema` (+ their + types) still live in `@objectstack/spec/ui`. + - **Removed** `NotificationConfigSchema` / `NotificationConfig` from **both** + `@objectstack/spec/system` and `@objectstack/spec/ui` — the bare name left + the spec export surface entirely. Both declarations had zero importers in + all three repos and were wired into no parent schema. The system side (a + channel + template + recipients + schedule + retryPolicy + tracking + "unified notification management protocol") predates ADR-0030's accepted + delivery architecture and advertised capability the runtime does not + deliver (its channel enum's `push`/`slack`/`teams`/`webhook` dead-letter, + #3197; nothing reads `schedule`/`retryPolicy`/`tracking`). The ui side (a + toaster global config: `defaultPosition`/`defaultDuration`/`maxVisible`/ + `stackDirection`/`pauseOnHover`) was never adopted by objectui. + - FROM `import { NotificationConfigSchema } from '@objectstack/spec/system'` (or `.../ui`) → + TO: no direct replacement. The live delivery vocabulary is + `NotificationService.emit` (`INotificationService`, + `@objectstack/spec/contracts`), the `notify` flow node + (`NotifyConfigSchema`, `@objectstack/spec/automation`) and the + `sys_notification*` platform objects; per-user delivery preferences are + `NotificationPreferences(Schema)` in `@objectstack/spec/api`. + - `@objectstack/spec/api`'s `Notification(Schema)` and + `NotificationPreferences(Schema)` are **unchanged**; `./api` is now the + sole owner of the bare `Notification(Schema)` names. Imports from `./api` + need no migration. `@objectstack/spec/system`'s `NotificationChannel(Schema)`, + `EmailTemplate(Schema)`, `SMSTemplate(Schema)`, `PushNotification(Schema)` + and `InAppNotification(Schema)` are **unchanged**. + + `dual-source-exports.baseline.json` shrinks by exactly these 4 rows (28 → 24, + #4535 C3). + +- 023c00b: feat(spec)!: `@objectstack/spec/system` no longer exports the orphan notification-template vocabulary — `EmailTemplate(Schema)`, `SMSTemplate(Schema)`, `PushNotification(Schema)`, `InAppNotification(Schema)` (#4616) + + These four schemas existed **only** as the member shapes of the + `NotificationConfigSchema.template` union, and #4610 (#4535 C3) deleted that + union. Since then they have been reachable from no parent schema and from no + metadata-type root: nothing in framework, cloud or objectui parsed a document + against them, so they declared delivery capability the runtime never read + (ADR-0049 enforce-or-remove, resolved by REMOVE in the v17 breaking window). + + Migration — one line each, and in every case the replacement already exists: + + - FROM `import { EmailTemplateSchema, type EmailTemplate } from '@objectstack/spec/system'` → + TO `import { EmailTemplateDefinitionSchema, type EmailTemplateDefinition } from '@objectstack/spec/system'`. + **Shape change** — this is a different, richer contract, not a rename: + `EmailTemplateDefinitionSchema` is keyed `name` + `locale` (not `id`), splits + the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`), and adds + `label` / `category` / `active` / `fromOverride` / `replyTo`. It is also a + `strictObject`, so the old keys are rejected loudly rather than stripped. + This is the schema the `email_template` metadata kind has resolved to since + spec **7.1.0**, which demoted `EmailTemplateSchema` when it fixed that Prime + Directive #8 double-declaration and kept it "only as an inline sub-shape + inside `Notification`" — #4610 removed that holder, and #4616 finishes the + job. If your code registers a client-side or publish-time validator for + `email_template`, it must point at `EmailTemplateDefinitionSchema`; + `BUILTIN_METADATA_TYPE_SCHEMAS` (`kernel/metadata-type-schemas.ts`) is the + authority. + - FROM `import { SMSTemplateSchema, type SMSTemplate } from '@objectstack/spec/system'` → + TO: no spec replacement, and none is needed. SMS templates are + `sys_notification_template` rows resolved by `(topic, 'sms', locale)` + (`service-messaging/src/sms-channel.ts`) and rendered by + `template-renderer.ts`; the provider-side template is Aliyun's pre-registered + `TemplateCode` in `service-sms` — a vendor API shape, never a spec constant. + - FROM `import { PushNotificationSchema, type PushNotification } from '@objectstack/spec/system'` + and FROM `import { InAppNotificationSchema, type InAppNotification } from '@objectstack/spec/system'` → + TO: no replacement. Neither channel has a delivery implementation (#3197): + the dispatcher dead-letters any message addressed to them, so these payload + shapes advertised a capability nothing delivers. The live delivery ingress is + `NotificationService.emit` (`INotificationService`, + `@objectstack/spec/contracts`); the in-app bell reads `./api`'s + `Notification(Schema)` inbox row; the presentation vocabulary is + `@objectstack/spec/ui` (`NotificationTypeSchema`, `NotificationSeveritySchema`, + `NotificationPositionSchema`, `NotificationActionSchema` — all unchanged). + + Unchanged and explicitly NOT part of this removal: + `@objectstack/spec/system`'s `NotificationChannel(Schema)` (live — re-exported + by `@objectstack/spec/contracts`, consumed by `service-messaging`), + `EmailTemplateDefinition*`, and every `@objectstack/spec/ui` notification + export. + + No ADR-0087 D2 conversion accompanies this change, deliberately: a conversion + rewrites authored or stored sources, and these defs were reachable from no + metadata-type root, so `os migrate meta` would have nothing to match. The + removal is a TypeScript export-surface break only — same disposition as #4610 + in this very module. `json-schema.manifest.json` loses 4 keys and + `authorable-surface.json` loses their 22 lines; both deletions are adjudicated + by `gen:schema`'s #4650 route-3 check ("def no longer emitted by this build"). + +- 7e05d8e: BREAKING(spec): `@objectstack/spec/kernel` 改名 `PackageDependencySchema` → `ResolvedPackageDependencySchema`;裸名 `PackageDependency(Schema)` 现在全包唯一地指 `@objectstack/spec/cloud` 的清单声明形 (#4741, #4535 C7) + + `PackageDependency` / `PackageDependencySchema` 曾由 `./cloud` 与 `./kernel` 各自导出一个声明 —— 同名、键集**完全不相交(0 个共享属性)**的**两个概念**(#4411 陷阱): + + | 入口 | 键集 | 语义 | 处置 | + | :------------------- | :-------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------- | + | `./cloud`(**保名**) | `packageId` / `versionRange` / `optional` | **声明形**:作者写进包清单的依赖行,嵌在 `PackageManifestSchema.dependencies[]` → `sys_package_version.manifest_json` | 裸名唯一归属 | + | `./kernel`(**改名**) | `name` / `versionConstraint` / `type` / `resolvedVersion` | **解析形**:依赖解析器在图上走的边,嵌在 `DependencyGraphNodeSchema.dependencies[]`,并经 `PluginSecurityProtocol` 发布给 SBOM / 冲突报告 | → `ResolvedPackageDependencySchema`,字段与校验逐字不变 | + + 两边都不是 `.strict()`,所以把一侧的文档粘到另一侧时**不会响亮报错,只会静默剥掉全部外来键**(ADR-0104 silent-strip 类)—— 这正是共用一个名字所掩盖的失败模式,也是本次不留任何别名的原因。 + + ## FROM → TO + + ```ts + // FROM —— 编译期起以 TS2305 失败 + import { + PackageDependencySchema, + type PackageDependency, + } from "@objectstack/spec/kernel"; + + // TO —— 同一声明、同一形状,名字点明它是「解析结果」而非「清单声明」 + import { + ResolvedPackageDependencySchema, + type ResolvedPackageDependency, + } from "@objectstack/spec/kernel"; + ``` + + 运行时命名空间对象同步改键:`PluginSecurityProtocol.PackageDependency` → `PluginSecurityProtocol.ResolvedPackageDependency`(指向同一 schema)。 + + **要的是清单里写的依赖声明?** `import { PackageDependencySchema, type PackageDependency } from '@objectstack/spec/cloud'` —— 本次未动其形状,仅补了一段互指 docblock。 + + **受影响面实测**:`objectstack` / `cloud` / `objectui` 三仓 import 级扫描,`./kernel` 侧零外部 importer(唯一读者是同文件的 `DependencyGraphNodeSchema` 与 `PluginSecurityProtocol`),预期无人受影响。零 importer 不等于有死侧可删(#4653 判则),故走改名而非删除。 + + 不保留旧名别名:在 `./kernel` 上 re-export 任何一侧的 `PackageDependencySchema` 都会重开本次关闭的陷阱 —— 要么复活双源,要么把清单声明形谎报成解析器合法输入(承接表不变式 3 会在 build 阶段直接拒绝这条路线)。 + + ## 零元数据迁移、零形状变更 + + 本次只动 TS 导出名与内部 JSON Schema def 名(`kernel/PackageDependency` → `kernel/ResolvedPackageDependency`,走 `RENAMED_DEFS` 承接表,**4 keys carry**:`name` / `versionConstraint` / `type` / `resolvedVersion` 在新 def 名下逐个健在)。 + + - **无字段增删、无类型变更、无词表变化** —— 两个 schema 的 body 一字未改(对照 C10 的 checksum 对象 → 字符串、C16 的 3→5 词表拓宽:本簇均不适用)。`type` 的 `.default('required')`、cloud 侧 `optional` 的 `.default(false)` 都原样保留,并由新增的 pin 用 `parse` 实测钉住(#4666 默认值盲区的自卫)。 + - **无 tombstone**:tombstone 的前提是有 key 退役;本次 4 个 key 全数承接,一个都没离开契约,伪造 tombstone 会污染 ADR-0087 登记(`renamed-defs.ts` 头注明列的第 2 种错误处置)。已按 #4767 的 `retiredKey()` 先例逐条评估后排除,非沉默跳过。 + - **无 ADR-0087 conversion**:没有作者路径发生位移,注册迁移等于让消费者跑一次不该跑的转换。 + - 发布的 JSON Schema `$id` 随之移动:`…/kernel/PackageDependency.json` → `…/kernel/ResolvedPackageDependency.json`。 + + ⚠️ 同前缀近邻 `PackageDependencyConflict(Schema)` 与 `PackageDependencyResolutionResult(Schema)` 是**不同概念**,一字未动,并由 pin 显式断言健在。 + +- c1f344b: BREAKING(spec): `@objectstack/spec/integration` renames `RateLimitConfig` → + `ConnectorRateLimitConfig` (#4684, C9) + + Two entry points exported `RateLimitConfig` for **two different declarations**, + so which one you got depended only on the import path — the #4411 trap. They are + not variants of one concept; they describe opposite directions of traffic: + + | | `@objectstack/spec/shared` (unchanged) | `@objectstack/spec/integration` (renamed) | + | :------------- | :-------------------------------------------------- | :----------------------------------------------------------------------- | + | what it limits | **inbound** — calls others make to our API | **outbound** — calls we make to an external system | + | written at | `apis[].rateLimit`, `httpServer.security.rateLimit` | `connectors[].rateLimitConfig` | + | window | `windowMs` (ms), defaults to 60000 | `windowSeconds` (s), **required**, min 1 | + | quota | `maxRequests`, defaults to 100 | `maxRequests`, **required**, min 1 | + | extras | `enabled` (default `false`) | `strategy`, `burstCapacity`, `respectUpstreamLimits`, `rateLimitHeaders` | + + Neither schema is `.strict()`, so a snippet copied from one side to the other + parsed **clean** with its foreign keys silently stripped — `RateLimitConfigSchema +.parse({ windowSeconds: 60, strategy: 'token_bucket' })` returned + `{ enabled: false, windowMs: 60000, maxRequests: 100 }` and nothing said a word. + Per ADR-0112 D9(a) — the same ruling that produced `ConnectorErrorCategory` and + `ConnectorRetryStrategy` in the same file — the **connector side is renamed** so + one name means one thing. + + ## FROM → TO + + ```ts + // before + import { + RateLimitConfigSchema, + type RateLimitConfig, + } from "@objectstack/spec/integration"; + + // after + import { + ConnectorRateLimitConfigSchema, + type ConnectorRateLimitConfig, + } from "@objectstack/spec/integration"; + ``` + + No deprecated alias is kept: re-exporting the old name would be a third + declaration of it and would re-open the trap this change closes. + + **Importing from `@objectstack/spec/shared` (or `/api`, `/system`)? Nothing + changes** — that `RateLimitConfig` keeps its name, its keys and its defaults. + + ## Authored metadata needs no migration + + This renames a TypeScript export and an internal JSON Schema `$def`, not an + authorable key. Every one of the six keys an author can write under + `connectors[].rateLimitConfig` — `strategy`, `maxRequests`, `windowSeconds`, + `burstCapacity`, `respectUpstreamLimits`, `rateLimitHeaders` — parses exactly as + before. Existing stack metadata, stored `sys_metadata` rows and published apps + are byte-for-byte unaffected, which is why this change ships with **no ADR-0087 + conversion and no tombstone**: nothing was retired. + + The only edit an upgrade needs is the import above, in TypeScript that named the + type. The published JSON Schema `$id` moves with it: + `…/integration/RateLimitConfig.json` → `…/integration/ConnectorRateLimitConfig.json`. + + ## Gate change riding along + + `scripts/build-schemas.ts` learns a declarative `RENAMED_DEFS` table + (`scripts/lib/renamed-defs.ts`). Its two ratchets measure in `$def` units, so a + def rename previously read as six authorable keys vanishing at once. The table + carries the old snapshot forward under the new name and enforces the rule a + rename must obey: **every key under the old def must exist under the new one, or + the build fails** — plus the target must be emitted and the source must not (a + def that is still published is a copy, not a rename). This is stricter than the + hand-edited baseline it replaces, which could drop any line without a trace. + +- 695cfbd: refactor(spec)!: remove the `RestServerConfig.openApi31` block — OpenAPI 3.1 webhooks/callbacks config that no runtime ever read (#4579, ADR-0049) + + `RestServerConfig.openApi31` (typed by `OpenApi31ExtensionsSchema`, with + `OpenApiWebhookEventSchema` and `CallbackSchema` under it) was authorable and + inert end to end — the declared ≠ enforced shape ADR-0049 exists to close: + + - The REST server's `normalizeConfig` (`packages/rest/src/rest-server.ts`) + forwards only `api` / `crud` / `metadata` / `batch` / `routes`; `openApi31` + was silently discarded. + - The served `GET /openapi.json` is the pre-generated `@objectstack/spec` + contract, enriched at request time with the live server URL and the + runtime-registered objects — it never consulted the config. + - `gen:openapi` (`scripts/build-openapi.ts`) never read a webhook or callback. + + So a webhook an author declared under `openApi31.webhooks` **never appeared in + any served OpenAPI document** — false compliance, the same class as the + connector-webhook gap (#3197). Zero import-level consumers existed for all + three schemas across objectstack / cloud / objectui (three-repo scan in #4579). + + Migration (FROM → TO): + + - `openApi31` in a `RestServerConfig` value (REST plugin constructor / + `plugin-hono-server` `restConfig`) → **delete the key**. There is no + replacement: nothing ever read it, so removing it changes no served + document. The key is tombstoned, not silently stripped — + `RestServerConfigSchema` is not `.strict()`, so a `retiredKey()` tombstone + makes authoring it a `tsc` error and a parse error carrying this + prescription. + - `import { OpenApi31Extensions(Schema), Callback(Schema), OpenApiWebhookEvent(Schema) } from '@objectstack/spec/api'` + → **no replacement export** (TS2305 after upgrade). For a real outbound + webhook use `Webhook` from `@objectstack/spec/automation`; for connector + webhook events use `WebhookEvent` from `@objectstack/spec/integration`. + (`OpenApiWebhookEvent(Schema)` was the #4572 rename of `./api`'s + `WebhookEvent(Schema)`; this removal absorbs that rename — pre-16 imports of + the bare name land here too.) + - Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a **new + capability**: if it is ever needed it returns via the enforce route of + ADR-0049, through a new ADR — not by re-declaring inert keys. + + The retirement kit: `retiredKey()` tombstone on the non-strict schema (parse + + `tsc` both audible); ADR-0087 D3 semantic migration + `rest-server-openapi31-block-removed` (plugin TS config is never a + `sys_metadata` shape — the stack tree's `api` block declares only its four + scoping/auth knobs — so there is no stored row or stack source for a D2 + conversion to rewrite); baselines (`authorable-surface.json` [RETIRED] line, + `json-schema.manifest.json` def removals, `api-surface.json`) regenerated + deliberately; compiler-API export pin + sabotage-verified tombstone tests. + + No runtime behaviour changes — that impossibility is the reason for the + removal: the served `/openapi.json` is byte-identical before and after. + +- 7445149: refactor(spec)!: remove `activationEvents` (both keys) and the `ActivationEventSchema` vocabulary — lazy activation that no runtime ever implemented (#4657, ADR-0049) + + `activationEvents` promised lazy plugin activation ("plugins remain dormant + until an activation event fires") on two authorable surfaces — + `DynamicLoadRequest.activationEvents` (`@objectstack/spec/kernel`) and + `StudioPluginManifest.activationEvents` (`@objectstack/spec/studio`, the + `defineStudioPlugin` input) — and **no runtime in objectstack / cloud / + cloud-v1 / objectui ever read either key** (four-repo bare-name scan in #4657, + re-verified at implementation time). Every plugin has always activated + immediately on load/registration; cloud-v1's own ROADMAP recorded lazy + activation as ❌ unimplemented (planned v0.4.0). That is ADR-0049's + declared ≠ enforced shape in the semantically-lying direction: an author + writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` + expected deferral and got eager activation with a clean parse. + + #4653 had just converged the two `ActivationEventSchema` declarations onto one + structured `{ type, pattern }` form inside this same unreleased major; with the + enforce-or-remove ruling landing on **remove**, that converged vocabulary + retires before ever shipping. Composed across the two changes, a v16 author + simply deletes the key in whichever form they carried. + + Migration (FROM → TO): + + - `activationEvents` in a `defineStudioPlugin` input / `StudioPluginManifest` + value — v16 string form (`['*']`, `['onMetadataType:flow']`) or v17-rc + structured form (`[{ type: 'onStartup', pattern: '*' }]`) alike → + **delete the key**. There is no replacement value: eager activation is the + only behaviour there has ever been, and `activate()` still runs at + registration time. The strict manifest parse rejects the key (and its former + VS Code-flavoured aliases `activation` / `events` / `onActivate`) with this + prescription. + - `activationEvents` in a `DynamicLoadRequest` value → **delete the key**. + Tombstoned, not silently stripped — `DynamicLoadRequestSchema` is not + `.strict()`, so a `retiredKey()` tombstone makes authoring it a `tsc` error + and a parse error carrying the prescription. + - `import { ActivationEventSchema, ActivationEvent } from '@objectstack/spec/kernel'` + (or `/studio`) → **no replacement export** (TS2305 after upgrade). Nothing + consumed the vocabulary; an exported schema with no consumer is read as a + capability by whoever finds it (#3950), so the orphaned def goes with the + keys. + - Lazy activation is a **new capability**: if it is ever built it returns via + the enforce route of ADR-0049 through a new ADR — executor first, vocabulary + second — not by re-declaring inert keys. + + Self-check (#4535 §5): TS2305 — yes, two removed exports on two entries; + metadata migration — none possible or needed (`StudioPluginManifest` is TS + configuration parsed by `defineStudioPlugin`, a root schema never stored in + `sys_metadata`; `DynamicLoadRequest` is a runtime request shape with no + caller — no stored row exists for a D2 conversion to rewrite, so the change is + one ADR-0087 D3 semantic record, `plugin-activation-events-retired`); shape + change — two keys removed, zero behaviour change (eager activation before and + after, byte-identical). + + The retirement kit: `retiredKey()` tombstone on the non-strict kernel schema; + strict-parse `guidance` prescriptions on the studio manifest (including the + three former aliases); ADR-0087 D3 semantic migration; baselines + (`authorable-surface.json` — one `[RETIRED]` line, five lines dropped + deliberately with the defs; `json-schema.manifest.json` — `kernel/ActivationEvent` + and `studio/ActivationEvent` def removals; `api-surface.json`) regenerated + deliberately; compiler-API export pin (`activation-events-retirement.test.ts`, + zero holders across every public entry) — sabotage-verified. + +- 071d0dc: chore(spec)!: retire `IDataEngine.batch?` — declared for the life of the contract, implemented by nothing, called by no one (ADR-0119 D3, #4618) + + **FROM → TO** + + | Removed | Use instead | + | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | + | `IDataEngine.batch?(requests, { transaction })` | `IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity | + | — a batch over ONE object | the metadata protocol's `batchData` with `options.atomic: true` | + | — a cross-object batch over the wire | `POST {basePath}/batch` | + | `DataEngineBatchRequestSchema` / `data/DataEngineBatchRequest` JSON schema | nothing — it described only the removed member | + + **One-line fix:** delete the `batch` implementation from any engine that has one (there were none in this repo) and route multi-write atomicity through `engine.transaction(cb)`. + + ## Why + + `batch?` was declared on `IDataEngine` for as long as that contract has existed and was **never implemented by any engine** — `ObjectQL` has no `batch` method, and there is no other engine in the tree. It also had **no caller**: `DataEngineRequest` was imported by exactly one file, the contract declaring the member. + + Its entire specification was a three-word doc comment, "Batch Operations (Transactional)", which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. + + The tell that nobody ever designed against it is in the schema. `DataEngineBatchRequestSchema.requests` nested the request union **recursively** — a batch could contain batches — with no statement anywhere about what that meant for ordering or rollback. + + The only test was a type pin: an ad-hoc object literal carrying a `batch` property, asserting the property was defined. It could not fail while the declaration existed, and would have passed unchanged for the member's entire life with no engine implementing it. A test that asserts a contract member is _declared_ is not evidence the contract is _honoured_. + + A declared capability that cannot be exercised is ADR-0049's enforce-or-remove target. What this one claimed is now covered by members that are real — ADR-0119 D1 made `transaction` reachable through the contract, D4 made `batchData`'s `atomic` honest — so the removal deletes a false affordance, not a capability. + + ## Scope notes + + - **The wire batch is untouched.** `POST {basePath}/batch` validates with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema that never had anything to do with the removed one. + - **`DataEngineRequestSchema` stays**, minus its `batch` arm. Every remaining arm now has zero readers in this repo (there is no Virtual Data Engine implementation, only this schema describing one), which makes the whole block a further enforce-or-remove candidate — tracked separately, because retiring a published wire protocol is a different decision from retiring `batch?` and does not belong in a change whose title promised something narrower. + - **Deliberately no `retiredKey()` tombstone.** A tombstone delivers its prescription through a _parse_, and nothing ever parsed `DataEngineBatchRequestSchema`. A prescription nobody can receive is noise (the `spec-property-retirement` playbook's third route). Its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are therefore dropped in this change, deliberately, along with the now-stale `docs-import-surface.baseline.json` line that excused its missing type export. The enforced channel here is `tsc`, and it points at callers. + +- b8b3c64: **Retry policy converges onto one declaration** (#4661 — the #4535 C8 dual-source cluster). + + `@objectstack/spec/automation` and `@objectstack/spec/system` both exported + `RetryPolicySchema` / `RetryPolicy`, resolving to **different declarations** — so the + shape you got depended only on which entry you imported (the #4411 trap). They were + never two concepts: the `try_catch` node's `retry` region and `job.retryPolicy` both + compute `delay = base * multiplier^(retry-1)`, and both executors implemented that + identical formula. There is now one declaration, re-exported by both entries, carrying + the union of what the two sides could express. + + ## FROM → TO + + | | FROM `./automation` | FROM `./system` | TO (both entries) | + | ------------------- | ----------------------------------- | ----------------------------------- | --------------------------------------------------- | + | base delay | `retryDelayMs`, min 0, default 1000 | `backoffMs`, positive, default 1000 | **`backoffMs`**, min 0, default 1000 | + | `maxRetries` | 0–10, default **0** | ≥0 unbounded, default **3** | 0–**10**, default **0** | + | `backoffMultiplier` | ≥**1**, default **1** | positive, default **2** | ≥**1**, default **1** | + | `maxRetryDelayMs` | default 30000 | _(absent)_ | default 30000 | + | `jitter` | default false | _(absent)_ | default false | + | `RetryPolicy` type | `z.input` | `z.infer` | `z.input` (+ new `RetryPolicyParsed` for `z.infer`) | + + ## What you must change + + **1. Rename `retryDelayMs` → `backoffMs`** in any `try_catch` node's `retry` block. + The value (milliseconds before the first retry) is unchanged. The old spelling is + **tombstoned**, not deleted — it rejects with the rename prescription instead of being + silently swallowed, because neither owning schema is `.strict()`. Automated: + + ``` + os migrate meta --from 16 + ``` + + **2. Nothing for existing jobs — but read this if you author new ones.** `maxRetries` + now defaults to **0** and `backoffMultiplier` to **1**, where `job.retryPolicy` + previously defaulted to 3 and 2. Left alone that would silently stop deployed jobs from + retrying, so the `retry-policy-converged` conversion **writes the pre-17 numbers + explicitly into every existing `job.retryPolicy`** that omitted them: + + ```jsonc + // before // after `os migrate meta` + { "backoffMs": 5000 } { "backoffMs": 5000, "maxRetries": 3, "backoffMultiplier": 2 } + ``` + + Deployed stacks therefore keep their exact behaviour. What changes is what a **newly + authored** omission means: declaring a retry block without `maxRetries` now means _no + retry_. Retry is opt-in because a retry replays whatever the attempt already did — a job + handler's writes and callouts, a `try` region's side effects — and an implicit replay is + the failure mode hardest to catch in tests and most expensive in production. (The same + reading is already recorded for flow-level retry in `flow-retry-max-retries-required`, + #4247.) + + > This defaults change is the part **no gate can see**: the authorable-surface ratchet + > compares key sets, and a default is not a key. It is called out here because a + > changeset is the only channel that carries it. + + **3. Two bounds now apply to jobs that did not have them** — `maxRetries` is capped at + **10** and `backoffMultiplier` floored at **1**. Both fail loudly at parse time rather + than being silently reinterpreted; neither has a lossless rewrite, so they are recorded + as the `job-retry-policy-constraints-tightened` semantic migration note. A multiplier + below 1 described a delay that _shrinks_ on each attempt — retrying a failing dependency + ever faster, the opposite of backoff. + + **4. `import type { RetryPolicy } from '@objectstack/spec/system'` is now the input + shape** (every key optional) rather than the post-parse shape. Use the new + `RetryPolicyParsed` where you need defaults applied. + + ## Not related to `mapping.errorPolicy` (#4509 / #4664, same release) + + 17.0.0 also retires `mapping.errorPolicy`, whose values included `'retry'`. That is a + different thing on a different type: an inert enum on the stored **mapping**, whose + prescription is "error handling on the import path belongs to the import REQUEST's own + options". It does **not** migrate to a `retryPolicy` block, and nothing in this change + affects it. + + ## What you gain + + `job.retryPolicy` accepts **`maxRetryDelayMs`** (ceiling on a single backoff delay) and + **`jitter`** (randomize each delay into [50%, 100%]). Both are enforced by + `runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed + on one outage from retrying in lockstep. + +- fd3013a: feat(spec,automation)!: converge `script` to a function call — retire the `actionType` branches — and parse `script` / `subflow` config at execute time (#4343) + + A `script` node had four ways to name what it ran and only one of them ran anything. + Protocol 17 keeps that one and retires the rest. + + - **`config.actionType: 'email' | 'slack'`** were **logger-backed stubs**. They wrote a + line, reported success, and delivered nothing — under any configuration, installed + messaging service or not. Every bundled example used one; none of them ever sent + anything. + - **`config.template` / `.recipients` / `.variables`** fed those stubs, so they addressed + a message no channel sent. (The examples did not even reach them: they passed the + payload in `inputs`, which the built-in branch never read.) + - **inline `config.script`** was recognized and **never executed** — the built-in runtime + has no server-side JS sandbox, so the node warned and completed as a no-op. + - **any other `actionType`** was shorthand for a registered-function name — a second + spelling of `config.function` — and `'invoke_function'` was a marker that named nothing + on its own. + + What remains is what worked: `config.function` (now **required**) names a registered + function, `config.inputs` feeds it, `config.outputVariable` binds its return value. + + **The replacements are three different mechanisms, not one rename.** + + | Retired | Use instead | + | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `actionType: 'email'` (+ `template` / `recipients` / `variables`) | a `notify` node — it delivers through the messaging service: the in-app inbox by default, real email once `@objectstack/plugin-email` is installed | + | `actionType: 'slack'` | a `connector_action` node with the Slack connector, or an `http` node posting to an incoming webhook — `notify` has no Slack channel | + | `actionType: 'my_fn'` (shorthand) | `function: 'my_fn'` — the conversion moves it for you | + | `script: '…'` (inline JS) | move the logic into a registered function and call it via `config.function` | + + **Execute-time parse.** `script` and `subflow` now run their config through the contract + before executing, the seam #4277 gave the flat builtins — a violation refuses the node as + a **guard** (wrong metadata; no `fault` edge may route it, #3863). `script` could not join + that seam while its legal key set depended on `actionType`: a flat parse would either + reject valid shapes or wave everything through. Converging the node is what made the + contract fit. `subflow`'s hand-written `flowName` check became the same parse, so its + message is now `subflow 'n1': config does not satisfy the subflow contract — +config.flowName: …`. `decision` deliberately stays export-only: its one key is optional, + so a parse would check nothing. + + **Migration.** `os migrate meta --from 16` rewrites stored sources; authoring one of these + keys in TypeScript is a compile error carrying the same prescription. A shorthand + `actionType` **converts into `function`** — that is what it named — unless `function` is + already set, in which case it was dead metadata the executor never reached. The other four + keys are dropped outright: nothing read them, so there is no value to preserve, and + rebuilding the intent is an authoring decision (the table above) rather than something a + mechanical rewrite can guess. + + The keys leave the **load path** (`retiredFromLoadPath`) with the rest of the keys retired + for _misdescribing themselves_ rather than for being renamed: absorbing + `actionType: 'email'` silently would let an author keep believing the flow sends mail. The + one seam that still replays it is `registerFlow`, which rehydrates data at rest (#3903) — + a row in `sys_metadata` has no author for a tombstone to teach. So a stored email-stub node + arrives stripped of the keys nothing read and then **refuses for naming no callable**, + where it used to log a line and report success. That flip is the behavior change to expect. + + **A build gap this surfaced, fixed here.** `FlowFunctionEntrySchema` now also accepts a + **lowered handler ref** (a non-empty string), the form `objectstack build` produces: the + CLI lowers every inline callable to a serialisable ref _before_ the stack is parsed (it + must — `z.function()` wraps callables and would break the ref mapping), so a built + manifest holds `{ myFn: 'myFn' }`, which neither previous member accepted. The result was + that `defineStack({ functions })` — a documented, first-class mechanism — could not + survive a build at all. Nothing had noticed because no bundled example used it; #4343 + turns that from latent into blocking, since `config.function` becomes the only thing a + `script` node can run. `Hook.handler` already declared exactly this pair (`z.union([ +z.string(), ])`, "string, post-build / inline function, pre-build"), so this + brings `functions` onto the platform's established shape rather than inventing one. A + string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the + real functions ride in the sibling ESM module the build emits, merged by name — so + hand-authoring one registers nothing and fails loudly at execute ("no function named '…' + is registered"), never silently. + + Also in this change: the retired constants `SCRIPT_BUILTIN_ACTION_TYPES`, + `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` and the `ScriptBuiltinActionType` type are removed + (they described the dispatch set that no longer exists); `os validate` names a retired key + and its replacement instead of reporting a generic missing callable; and the `#3796` + alias fixture, which carried `actionType: 'invoke_function'` through both sides, no longer + describes an end state protocol 17 can reach — the rename itself is untouched. No liveness + ledger row moves: the gate walks `FlowSchema`, whose `nodes[].config` is + `z.record(z.unknown())`, so these keys were never governed by one. + +- 21676eb: Resolve the `Session` dual source — `./api` keeps the bare names, the `./identity` declaration is removed (#4641) + + `Session` and `SessionSchema` were each declared **twice**, once on + `@objectstack/spec/api` and once on `@objectstack/spec/identity`. Which shape a + consumer got depended only on which entry point they imported from — the #4411 + trap — and the two did not even agree on field names, so the mistake surfaced as + a runtime `undefined`, not a type error. + + **FROM → TO** + + | Import | Before | After | + | :--------------------------- | :------------------------------------------------------ | :------------------------------------------- | + | `@objectstack/spec/api` | `Session` / `SessionSchema` | unchanged — this is now the only declaration | + | `@objectstack/spec/identity` | `Session` / `SessionSchema` (a second, different shape) | **removed** | + + The surviving `./api` shape is the wire contract: + + ```ts + { id: string; expiresAt: string; token?: string; ipAddress?: string; userAgent?: string; userId: string } + ``` + + It is embedded in `SessionResponseSchema`, the body served for + `AuthEndpointPaths.getSession` (`/get-session`, `/me`, `/refresh`). + + The removed `./identity` shape was + `{ id, sessionToken, userId, activeOrganizationId?, expires, createdAt, updatedAt, ipAddress?, userAgent?, fingerprint? }`. + + **Nothing consumes it.** An import-statement-level scan across framework, `cloud` + and `objectui` found no importer outside its own unit test, and it was wired into + no parent schema. It had also drifted from the record it claimed to describe: the + **enforced** session row is the `sys_session` object in + `@objectstack/platform-objects`, which spells the columns `token` and + `expires_at` (matching `./api`, not `./identity`) and has no `fingerprint` at all. + + **If you were importing `Session` from `@objectstack/spec/identity`**, change the + specifier to `@objectstack/spec/api` and rename the fields you read: + `sessionToken` → `token`, `expires` → `expiresAt`. `createdAt` / `updatedAt` / + `activeOrganizationId` / `fingerprint` are not on the wire shape — read the + persisted record through the `sys_session` object, which is what the migration + and the auth plugin actually enforce. + + Reference docs follow the declaration: `Session` is now documented on the + `references/api/auth` page (the module that declares it) instead of the + name-collision page `references/api/identity`, which is removed. + +- 5647006: BREAKING(spec): 删除 `@objectstack/spec/system` 的整个 tenant-provisioning 家族(`TenantPlan(Schema)` / `TenantRegion(Schema)` / `TenantProvisioningStatus(Enum)` / `ProvisioningStep(Schema)` / `TenantProvisioningRequest(Schema)` / `TenantProvisioningResult(Schema)`)及 `@objectstack/spec/contracts` 的 `IProvisioningService` / `ITenantRouter` / `ResolvedTenantContext` —— `TenantPlan(Schema)` 现在全包唯一地指 `@objectstack/spec/cloud` 的 5 值声明 (#4739, #4535 C16) + + `TenantPlan` + `TenantPlanSchema` 过去被两个入口导出,但**不是同一个声明**,拿到哪个词表只取决于 import 路径 —— #4411 陷阱: + + | 入口 | 声明位置 | 词表 | 状态 | + | :------------------------------------------- | :--------------------------- | :------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- | + | `@objectstack/spec/cloud`(**不变,唯一真源**) | `cloud/tenant.zod.ts` | `free / starter / pro / enterprise / custom`(5 值) | **活**:嵌入 `EnvironmentSchema.plan`、`TenantContextSchema.plan`、`ProvisionTenantRequestSchema.plan`,cloud 仓 service-tenant 经这些 schema 实际消费 | + | `@objectstack/spec/system`(**本次删除**) | `system/provisioning.zod.ts` | `free / pro / enterprise`(3 值子集) | **declared-only**:仅嵌入 `TenantProvisioningRequest/Result`,该 provisioning 协议三仓(objectstack / cloud / objectui)零实现零调用 | + + system 侧不是孤立的一个枚举,而是一整套从未实现的 provisioning 协议:`TenantProvisioningRequest/Result` 加上 `contracts` 里的 `IProvisioningService` / `ITenantRouter` 契约,import 语句级三仓实测**零实现、零调用、零 importer**(cloud 仓 `service-tenant` 的 `TenantProvisioningService` 是重名的本地类,消费的是 `@objectstack/spec/cloud` 的 `ProvisionTenantRequest`,与被删家族无关)。它已被 cloud 入口的 `Provision*` 家族整体取代,故按维护者裁决(#4739,路线 B)删除 system 侧全家族,cloud 侧一字未动。 + + ## FROM → TO + + ```ts + // FROM —— 编译期起将以 TS2305 失败(实测三仓零命中,预期无人受影响) + import { TenantPlanSchema, type TenantPlan } from "@objectstack/spec/system"; + // TO —— 唯一声明(注意词表从 3 值子集变为 5 值全集:多出 starter / custom) + import { TenantPlanSchema, type TenantPlan } from "@objectstack/spec/cloud"; + ``` + + ```ts + // FROM —— TS2305 + import type { + TenantProvisioningRequest, + TenantProvisioningResult, + } from "@objectstack/spec/system"; + import type { + IProvisioningService, + ITenantRouter, + ResolvedTenantContext, + } from "@objectstack/spec/contracts"; + // TO —— 活的 provisioning 协议在 cloud 入口 + import type { + ProvisionTenantRequest, + ProvisionTenantResponse, + TenantContext, + } from "@objectstack/spec/cloud"; + ``` + + - `TenantRegion(Schema)` / `TenantProvisioningStatus(Enum)` / `ProvisioningStep(Schema)`:无同名替代物。region 与 step 追踪从未接入任何运行时;租户生命周期状态的活表面是 `@objectstack/spec/cloud` 的 `TenantDatabaseStatusSchema` 与 `EnvironmentSchema.status`。 + - 类型收窄注意:改从 `./cloud` import 后,`TenantPlan` 联合类型从 3 值**变宽**为 5 值 —— 对 plan 做穷举 switch 的代码要补 `starter` / `custom` 两个分支(这正是双源曾经隐藏的漂移)。 + + ## 定级理由(逐条自证,未照抄前例) + + 定 **major**,因为这是**已发布导出名的移除**:外部对上述 15 个导出名(`./system` 12 个 + `./contracts` 3 个)的 import 会以 TS2305 编译失败(与 C14 / C6 同形)。 + + 同时它是**零元数据迁移**: + + - 被删 6 个 def(`system/ProvisioningStep` / `system/TenantPlan` / `system/TenantProvisioningRequest` / `system/TenantProvisioningResult` / `system/TenantProvisioningStatusEnum` / `system/TenantRegion`)均不从 `BUILTIN_METADATA_TYPE_SCHEMAS` 元数据根可达 —— #4650 门禁对 `authorable-surface.json` 被删 21 行的实跑判定是三组「def no longer emitted by this build」自证路径(输出见 PR),**无 tombstone、无 ADR-0087 conversion / migration**(`spec-changes.json` / upgrade-guide 两 gate 零变化)。 + - 已存 `sys_metadata` 数据、运行时校验行为全部不受影响;`cloud/tenant.zod.ts` 与 `system/tenant.zod.ts`(`system/Tenant` 行级租户记录,自带内联枚举、不引用被删文件)一字未动。 + - JSON Schema 产物中上述 6 个 def 停止发布(`json-schema.manifest.json` 同步删键,#2978 蓄意移除通道)。 + + ## 基线 12 → 10 + + `dual-source-exports.baseline.json` 删掉 `TenantPlan — [./cloud (type)] ≠ [./system (type)]` 与 `TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]` 两行,其余行一字未动。 + +- c57f3cf: The `trigger-registry.zod.ts` Connector cluster is removed (#4499) + + `@objectstack/spec/automation` no longer exports the third declaration of the + connector vocabulary: `ConnectorSchema`, `ConnectorInstanceSchema`, + `ConnectorOperationSchema`, `ConnectorTriggerSchema`, `ConnectorCategorySchema`, + `AuthenticationSchema` / `AuthenticationTypeSchema` / `AuthFieldSchema` / + `OAuth2ConfigSchema`, `OperationTypeSchema` / `OperationParameterSchema`, their + inferred types, and the `Connector.apiKey()` / `Connector.oauth2()` factory + helpers — 630 lines, all of `automation/trigger-registry.zod.ts`. + + Despite the filename, the file contained no trigger registry. Every export was + connector vocabulary, self-contained and read by nothing: + + - the automation engine registers and validates connectors against + `ConnectorSchema` from `integration/connector.zod.ts` (ADR-0097) — it never + imported this one; + - the stack `connectors:` collection parses `DeclarativeConnectorEntrySchema`; + - outside the spec package, the only references anywhere in the monorepo were + the two documentation generators that published it. + + This closes the connector triple-declaration: `integration/connector.zod.ts` + is the one live contract (ADR-0097), the six per-provider "templates" fell in + #4480, and this cluster is the last copy (Prime Directive #12 — one + capability, one contract). + + **Migration.** If you imported any of these names from + `@objectstack/spec/automation`, there is nothing to migrate _to_ on that + module: nothing ever consumed what you built against them. Declare real + connector instances with `defineConnector` / the stack `connectors:` collection + (`DeclarativeConnectorEntrySchema`), or materialize them from a provider + document via connector-openapi / connector-mcp. Note the name collision when + migrating types: the live `integration/connector.zod.ts` also exports a + `ConnectorTriggerSchema` and a `Connector` type with _different shapes_ — a + find-and-replace of the import path is not a migration. + + The removal also deletes the "When to use Integration Connector vs. Trigger + Registry?" comparison from `integration/connector.zod.ts`'s header, which + steered "lightweight" cases to the dead file with the platform's authority — + the same defect class as the `capabilities.readOnly` prescription #4487 + corrected. No D2 conversion: none of this was storable stack metadata, so + there is no source for `os migrate meta` to rewrite. + +- ce92674: feat(spec)!: retire the standalone `validation` metadata kind (#4509, ADR-0088) + + A validation rule authored as its own artifact bound to nothing and gated no + write. `ValidationRuleSchema` carries **no object-binding key** — no `object`, + no `objectName` — and all six variants are `strictObject`, so an author could + not supply one either. No merge step existed. The only code that expected such a + key was a reference-tracker row scanning a field the schema would have stripped. + Meanwhile the engine evaluates exactly one shape: the object's own + `validations[]` array, on insert and on every matched update row. + + So a rule created through the standalone door — a `*.validation.ts` file, or + Studio's Validations list — parsed, saved, reported success, and intercepted + nothing. Including a `state_machine` rule, which ADR-0020 routes through this + same vocabulary: an author could believe they had locked down record state + transitions and have changed nothing at all. + + Under ADR-0088 the kind fails the admission test on its first clause: a rule has + no independent lifecycle, because it only means something against an object. And + unlike the sibling disconnects closed in this batch, it could not be bridged into + one — the shape has nowhere to name its object. + + **The rule vocabulary is untouched.** `ValidationRuleSchema` and all six + variants are unchanged and fully live; the engine's evaluation path is not + modified by this change. It is the _kind_ that was inert, not the schema. The + liveness ledger keeps governing it through the gate's `SPEC_ONLY_SCHEMAS` + override (alongside `webhook` and `query`), because an ungoverned live schema is + exactly how the next drift would hide. + + **Migration.** Move the rule into the owning object's `validations:` array — the + rule body is identical, same schema, same six variants: + + ```ts + // before — a standalone *.validation.ts, which never ran + export default defineValidation({ name: 'amount_positive', type: 'script', … }) + + // after — on the object, where rules are evaluated + ObjectSchema.create({ + name: 'invoice', + validations: [{ name: 'amount_positive', type: 'script', … }], + }) + ``` + + Removed: the registry entry (and its `*.validation.ts` / `*.validation.yml` + patterns), the `MetadataTypeSchema` member, the metadata-core lockstep enum + member, the schema-map entry, the create seed, Studio's Validations nav item and + its hand-crafted form, and the dangling reference-tracker row. Standalone rows + already in `sys_metadata` are left alone — they were never evaluated, so nothing + changes behaviorally. + +- 355e951: feat(spec)!: `@objectstack/spec/api` no longer exports the bare names `WebhookConfig` / `WebhookEvent` — they belong to `./integration` alone (#4572) + + The names `WebhookConfig(Schema)` / `WebhookEvent(Schema)` resolved to **two + different declarations** depending on the import path (`./api` vs + `./integration`) — the #4411 dual-source trap, and a cross-form one: + `./api`'s `WebhookEventSchema` was a `z.object` (an OpenAPI 3.1 webhook + _definition_ descriptor: `name`/`description`/`method`/`payloadSchema`/ + `security`) while `./integration`'s is a `z.enum` of connector event types + (`'record.created'` … `'rate_limit.exceeded'`). Auto-importing the wrong side + compiled and validated the wrong contract. Resolution (three-repo, + import-statement-level consumer scan: framework, cloud, objectui): + + - **Removed** `WebhookConfigSchema` / `WebhookConfig` from + `@objectstack/spec/api`. This pair was dead: wired into nothing — not even + `RestServerConfigSchema` — with zero import-level consumers in all three + repos, and no runtime ever read a REST-server webhook config. + - FROM `import { WebhookConfig } from '@objectstack/spec/api'` → + TO: no replacement exists for a REST-server webhook config (it never had a + runtime). For a real outbound webhook use `Webhook` from + `@objectstack/spec/automation`; for a connector webhook use + `WebhookConfig` from `@objectstack/spec/integration` (a **different + shape**: it extends the canonical automation `WebhookSchema` with + `events` / `signatureAlgorithm`, and has no `deliveryConfig` / + `registrationEndpoint` / `enabled`). + - **Renamed** `WebhookEventSchema` / `WebhookEvent` in `@objectstack/spec/api` + → `OpenApiWebhookEventSchema` / `OpenApiWebhookEvent` (same shape, rename + only; joins the existing `OpenApi*` family). + **Superseded in the same major (#4579, ADR-0049):** the renamed pair was + then removed outright with the whole inert `RestServerConfig.openApi31` + block, so the rename never ships as a landing spot — see the + `rest-server-openapi31-block-removed` changeset. + - FROM `import { WebhookEvent } from '@objectstack/spec/api'` → + TO: no `./api` replacement exists (the OpenAPI 3.1 webhook descriptor was + removed in #4579 — nothing ever rendered it into the served + `/openapi.json`). Use + `import { WebhookEvent } from '@objectstack/spec/integration'` if you + meant the connector event enum (check which shape you actually consume: + object vs string enum), or `Webhook` from `@objectstack/spec/automation` + for a real outbound webhook. + - `@objectstack/spec/integration`'s `WebhookConfig(Schema)` / + `WebhookEvent(Schema)` are **unchanged** and are now the sole owners of the + bare names. Imports from `./integration` need no migration. + + `dual-source-exports.baseline.json` shrinks by exactly these 4 rows (35 → 31, + #4535 C1). + +- dadb43f: refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot — declared end to end, implemented nowhere (#4451) + + The `workflow` slot was ADR-0078's silently-inert declaration at every layer at + once: a `CoreServiceName` nothing ever registered or resolved (ADR-0115 + Evidence 5 — "no code in this repository resolves either slot", verified across + both repositories), an `IWorkflowService` contract with zero implementations, a + `WorkflowProtocol` whose three methods no code ever provided, a discovery + `routes.workflow` field no builder could truthfully populate, and a + `/api/v1/workflow` advertisement for a path no host ever mounted (the + pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed it among routes that + never existed). The capability it promised is live elsewhere and has been for + majors: record state machines are enforced by the `state_machine` validation + rule, approvals are first-class flow nodes on the approvals runtime + (ADR-0019), and record-triggered automation is lifecycle hooks + + `record_change` flows (`service-automation`). + + FROM → TO: + + - `CoreServiceName 'workflow'` / `ServiceRequirementDef.workflow` / + `CORE_SERVICE_PROVIDER['workflow']` → removed; there is no slot to fill. + - `IWorkflowService` (`@objectstack/spec/contracts`) → removed; no + implementation ever existed. Register nothing — use the mechanisms above. + - `WorkflowProtocol` + `GetWorkflowConfigRequest/Response`, + `WorkflowState`, `GetWorkflowStateRequest/Response`, + `WorkflowTransitionRequest/Response` (`@objectstack/spec/api`) → removed, + along with the seven published JSON schemas. Delete the import; nothing + ever answered these shapes. + - Discovery `routes.workflow` / `services.workflow` / `features.workflow` + (metadata-protocol + runtime builders) → absent. A reader keying on them + only ever saw `unavailable` / `false`; delete the read. + - `RouterConfig.mounts.workflow` → removed; there was never a surface to + mount at it. + - `RestApiRouteCategory 'workflow'` → removed; categorize automation-adjacent + routes as `'automation'`. + - `@objectstack/client` re-exports of the four workflow types → removed with + their source. (The `client.workflow.*` methods were already removed earlier + in the v17 cycle — this retires the types they returned.) + - Also removed: the stray `graphql` entry in `CORE_SERVICE_PROVIDER` and the + `graphql: { route: '/graphql' }` discovery entry — `graphql` was never a + `CoreServiceName`, and the dispatcher had already dropped `/graphql` as out + of the product plan (#2462 follow-on). + + The retirement kit: the `workflow-service-slot-retired` semantic migration + (major 17) carries this prescription into `spec-changes.json`, the generated + upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and a + discovery response field — never stored in stack metadata — so there is no + load-path conversion and nothing for `os migrate meta` to rewrite; the + 21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` + entries for the deleted schemas are dropped deliberately in the same change + (the plugin-runtime precedent: a prescription nobody can receive is noise — + nothing parses these shapes any more). + +### Minor Changes + +- 80334c7: `action` rejects unknown keys, and the ADR-0010 protection-envelope debt list reaches zero. + + `ActionParamSchema` has been strict since #3746 — the template this whole campaign was generalized from, and the source of its sharpest lesson: `visibleWhen` → `visible` showed that the most valuable entry in an alias table is rarely a typo, it is a key that reads as a control and silently is not one. The action _around_ the param stayed open for three more releases. + + **The AI exposure block is the reason this one mattered.** `ActionAiSchema` is the governance gate — its own doc says the platform's value is that "a human can govern exactly which capabilities the agent fleet is allowed to invoke", and that "a half-finished or unreviewed action must never be silently armed". Yet `requireConfirmation` (one letter off `requiresConfirmation`) was dropped in silence, so an author who asked for a human-in-the-loop gate on an AI-invoked action did not get one and was not told. Both that block and the action root now reject, with prescriptions for the two keys authors reach for at the wrong level (`exposed` and `requiresConfirmation` belong under `ai`). + + **An action's capability gate is real, and the near-misses now rename onto it.** `requiredPermissions` (ADR-0066 D4) is enforced with a 403 on the platform action route, so `permissions` / `capabilities` / `acl` are aliased to it rather than being told the gate lives elsewhere. What _is_ tombstoned is the trap beside it: `visible` and `disabled` are UI predicates — **they hide or grey a button, they do not stop a request** — and an action with no UI surface is `locations: []`, still gated. + + That entry was wrong in the first draft of this change, in the direction that matters. It claimed an action carries no permission key and sent authors to the object's permission sets, which — had anyone followed it — invites deleting a working `requiredPermissions` gate. Caught by checking the docs the drift report flagged (`ui/actions.mdx` teaches exactly that key) against the schema. It is the ledger's finding 7 for the fourth time: **this campaign's own prescriptions are themselves a surface that can be confidently wrong**, and the only defence is verifying each one against the schema rather than against memory of it. + + `resultDialog` and its fields, the AI param hints, and the `bodyShape` wrapper close alongside. + + **The undeclared-envelope debt list is now empty.** The structural walk opened it with eight names (`action`, `book`, `field`, `job`, `mapping`, `page`, `translation`, `validation`) after replacing a probe that had been hiding seven of them; `action` was the last. The empty set is kept rather than deleted — with no exemptions, the `DECLARES the protection envelope` case now runs over every registered type, so a new type shipping without the spread fails immediately instead of being quietly added to a list. Adding a name back is filing a bug, not granting an exemption. + + Registered types closed at the top level: **24 of 25**. Only `view` remains. + + Two things the lint layer surfaced, recorded rather than papered over: + + - **The array-index test has run out of subject.** It was `pages[].regions[]`, then `objects[].actions[]`; with `action` closed there is no declared array-of-objects left anywhere in the registered surface that is still strip-mode. The walker's array handling is unchanged and still correct — what is gone is any metadata type that exercises it. The test now asserts the hand-off plus the per-node descent under a closed root (the #4522 fix), and says in place that an indexed assertion should be restored if a new strip surface ever appears. + - **`view` is the last open root**, so when it closes this layer has nothing left to warn about at a root. The test says to change the floor to 0 and assert the empty set _deliberately_ — not to delete the test, because an empty result nobody chose is indistinguishable from a derivation that broke. + + Authoring impact: a key `ActionSchema` does not declare is now rejected instead of silently discarded — it was already being ignored, so no working action changes. + +- ce5242c: feat(auth,objectql,audit,security,spec): identity-table writes carry the real actor, so `sys_member` history stops saying "system" (#4586) + + better-auth owns every write to the identity tables (`sys_member`, `sys_user`, + `sys_invitation`, …) and its ObjectQL adapter runs them `isSystem: true` **on + purpose** — the route already authorized the action under better-auth's own ACL, + and ADR-0092 D2 refuses user-context writes to those tables outright. The + consequence was that the human who clicked _make admin_ was known exactly once, + in the hook layer where the session exists, and then discarded: every + `trackHistory` transition on `sys_member` recorded `user_id: null` / "system", + and `sys_user_permission_set.granted_by` was written null by the auto-grant. + "Who made this person an org admin?" had no answer in the platform's own audit + log. + + **What changed** + + A request-scoped attribution seam, general rather than a `sys_member` special + case: + + | Layer | Before | After | + | :--------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | + | `ExecutionContext` | `userId` / `actor` only | new optional `attributedUserId` — the human CREDITED for a write the system AUTHORIZED | + | `HookContext` | `session`, `user` | new `provenance.attributedUserId`, split off the context beside `session` | + | better-auth ObjectQL adapter | `{ isSystem: true }` | `{ isSystem: true, attributedUserId }` when a request scope is open | + | audit writer | `user_id = session.userId ?? null` | falls back to `provenance.attributedUserId` when the session names nobody | + | `auto-org-admin-grant` | `granted_by: null`, no `reason` | the attributed human in `granted_by`, plus a machine-provenance `reason` naming the writer and the triggering `sys_member` row | + + Outside a request scope nothing changes: writes stay bare `{ isSystem: true }` + and audit rows keep recording `null`. Absence is still never upgraded into a + caller, and never written as a sentinel string (ADR-0118 D1/D2). + + **Hard constraint — attribution is not authority** + + `attributedUserId` is read by exactly one consumer, the audit writer, and by no + security middleware. It never becomes `ExecutionContext.userId`, so it is never + the subject the engine authorizes as: not RLS `current_user`, not the ownership + stamp, not permission resolution. A context carrying only `attributedUserId` + authorizes exactly like an empty context (ANONYMOUS), and a context carrying it + beside `isSystem: true` authorizes exactly like `isSystem` alone. Re-authorizing + identity writes as the human would re-adjudicate a decision better-auth already + made — the second adjudication track ADR-0095 D3 closed. The constraint is + pinned by tests at three layers: the engine seam + (`packages/objectql/src/engine.test.ts`), the better-auth adapter + (`packages/plugins/plugin-auth/src/auth-actor-attribution.test.ts`), and the + live HTTP route (a plain member still cannot promote themselves). + + **For authors and plugin developers** + + `attributedUserId` is authorable on `ExecutionContext` and readable as + `ctx.provenance?.attributedUserId` in hooks. Use it to answer _who is + responsible_; keep using `ctx.session` / `ctx.user` to decide _what is + permitted_. The two are separate fields precisely so the distinction cannot be + blurred by accident. + +- a7163ea: The ADR-0078 completeness gate ships: a Zod-valid metadata instance that silently does nothing now fails at author time, on every authoring surface. + + This closes the hole _between_ the platform's existing gates. An instance can be Zod-valid (gate 1 green), use only _live_ properties (gate 2 green), and a correctly-authored sibling can be proven to run (gate 3 green) — and still be dead, because it omits a config its consumer needs and the consumer silently no-ops. The founding case (cloud#687): an AI authored `{ type: 'summary' }` with no `summaryOperations`; the engine's index builder skips it, the field reads 0 forever, the dependent "occupancy rate" is stuck at 0 — and the agent reported the work done, because every gate it could see was green. + + **Why this is worse than the unknown-key hole #4001 just closed.** There, the author wrote a key we don't know, and the parse now rejects it with a prescription. Here every key is one we know, the schema is satisfied, nothing warns, and the author gets a success. It manufactures false completion without the author mistyping anything — and the review step that catches a human's bare summary (seeing the field render `0`) is exactly the step AI authoring removes. + + **One shared predicate, every surface — the ADR's core decision.** Instance-completeness checks previously existed _only_ in cloud's AI-build graph-lint, so a stack authored with `os` + a coding assistant, an MCP agent, `os validate` in CI, or by hand got none of them (`formula_without_expression` existed nowhere in the framework). The judgement now lives in `@objectstack/spec/kernel`'s `checkFieldCompleteness` / `checkViewCompleteness` — sibling of `isIncoherentAggregate`, the ADR-0019 pattern — consumed by the new `@objectstack/lint` `validate-functional-completeness` and registered as an author-time rule (28 → 29), so `os build` / `os validate` / `os lint` / MCP / hand authoring are all covered. Cloud graph-lint can re-home its duplicate rules onto the same predicate rather than drifting from it. + + **Every rule cites the runtime line that makes it true**, because the completeness audit's scariest candidate — a "sharing rule fails open and shares every record" — collapsed on a three-file read, and #4001's last two batches shipped four confidently wrong prescriptions before learning the same thing: + + | rule | the silent skip | severity | + | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------- | + | `field/summary-without-operations` | `engine.ts` — `if (!d.summaryOperations) continue` | error | + | `field/formula-without-expression` | `engine.ts` builds the formula plan only from fields that HAVE one | error | + | `field/relationship-without-reference` | `$expand` — `if (!referenceObject) continue` | error | + | `field/choice-without-options` (`select`, `radio`) | `record-validator.ts` — an empty option list disables server-side value validation | error | + | `field/choice-without-options` (`checkboxes`) | same branch, but shared with free-form | warning | + | `view/layout-without-binding` (`kanban`, `calendar`, `gantt`) | renderer falls back to literal default field names | warning | + + **The deliberate NON-rules are pinned as hard as the rules.** `multiselect` without options is _not_ flagged: `record-validator.ts` says verbatim `// free-form (tags without options)`. The runtime blesses it as a mode, which makes it ADR-0078 case (3) "genuinely optional" — flagging it would be another false prescription, and the test is where that attempt fails first. `timeline` / `tree` views are likewise out of v1: they have config schemas, but their renderer behaviour has not had its verification pass. + + **It found a real one on its first run against a real app.** `showcase_field_zoo.f_summary` was a bare `Field.summary({ label: 'Roll-up Summary' })` — one line below an `f_formula` that _is_ complete, in the object whose entire job is to show what each field type looks like. So the canonical example of a roll-up in this repo computed nothing. It could not be fixed by adding `summaryOperations`: a roll-up aggregates a child into its parent, and the zoo is a leaf (`f_master_detail` makes it a child of `showcase_project`, and nothing is a child of the zoo). Removed, with the working examples named — `showcase_invoice.total` for the plain sum, `showcase_expense_report.total_amount` / `approved_amount` for the `summaryOperations.filter` variant. The rule it broke was the file's own: "relationship types point at the other showcase objects so they have REAL targets." + + Tracked in #4544. This is Phase 1; Phase 2 (the cloud authoring-path config-drop fix) is in the `cloud` repo, and Phase 3 lands the Tier-B shapes one verification pass at a time. + +- e6e9379: ADR-0078 Phase 3: a webhook with no `triggers` now fails at author time — and the Tier-B candidate list is corrected to what verification actually supports. + + **The rule.** `webhook/without-triggers`, error severity, in the shared `@objectstack/spec/kernel` predicate alongside the Phase 1 rules, walked by `@objectstack/lint`'s `validate-functional-completeness` over `stack.webhooks` in both collection spellings. A webhook that declares no trigger materializes into `sys_webhook`, renders in Setup looking armed, and delivers nothing. + + **Why it needed two sources, and why the first one argued against it.** The runtime skip site reads: + + ``` + if (triggers.size === 0) { + // No dispatchable triggers (or a manual-only webhook with none) — + // skip auto-enqueue. + return null; + ``` + + That parenthetical _blesses_ the empty case as a deliberate mode — structurally identical to the `multiselect`-without-options NON-rule, where `record-validator.ts`'s `// free-form (tags without options)` is exactly why we do not flag it. On that evidence alone this candidate stays unenforced. + + The mode it names does not exist. `webhook.zod.ts`'s #3196 note records that the `api` (manual/programmatic fire) trigger was _removed_ because "no manual fire path exists — the only webhook HTTP surface re-queues already-failed deliveries". There is no way to fire a webhook the auto-enqueuer dropped. Inert on every path, so: `error`. + + > **The generalization, now written into the module and pinned by a test:** a runtime comment records what its author believed, and beliefs go stale when a sibling feature is deleted. A blessing has to be corroborated by something showing the blessed mode is still _reachable_ — otherwise it is a comment about a mode that no longer exists. The test asserts the finding carries both citations, so nobody demotes this rule on the strength of the comment alone. + + `triggers: []` is flagged identically to an omitted `triggers`. Unlike an action's `locations: []` — the documented headless spelling — an empty array here carries no "I meant it" signal, because turning a webhook off has its own key (`isActive`). The repo's one real webhook (`showcase_task_changed`) confirms it: shipped inactive via `isActive: false`, with a full trigger list. + + **The corrected Tier-B disposition.** Phase 3 was scoped from the 2026-06 audit's Tier-A/B catalog. Verifying each candidate before writing it — the discipline that caught four false prescriptions in #4001 — found most of the list already closed or misfiled: + + | candidate | disposition | + | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | + | A2 action without `locations` | **already shipped** — `validate-action-locations.ts`, which already exempts the documented `locations: []` | + | B approval empty/unresolvable approvers | **already shipped** — `validate-approval-approvers.ts` | + | B select/multiselect without options | shipped in Phase 1 | + | B write-side referential integrity | **not an authoring-lint item** — a runtime gap; no metadata omission to detect | + | B `unique:true` no-op on memory driver | **not an authoring-lint item** — a driver gap | + | B composite/repeater sub-field constraints | **not an authoring-lint item** — a runtime gap | + | B nav targets of type page/report/url/component/action | **genuine gap, different module** — the key is present but dangling, which is reference resolvability (ADR-0072), not completeness (ADR-0078) | + | B dataset with zero measures | **unverified — not shipped.** No runtime consumer in this repo; the dataset compiler lives elsewhere | + | B webhook without triggers | ✅ **this change** | + | B schedule trigger with invalid cron | **unverified — not shipped.** `normalizeSchedule` accepts any non-empty string, but the scheduler's behaviour on an invalid one was not traced | + + Two candidates are deliberately left unshipped rather than written on the audit's stated confidence, and one is left for the module that actually owns it. The audit's own lesson stands: it produces _candidates_, not confirmed bugs — the scariest one collapsed on a three-file read. + + Tracked in #4544. + +- 98877c9: feat(core,platform-objects,spec): the ADR-0119 D2 migration-journal runner — a migration killed mid-run is resumable to completion or compensable to clean, with journal rows proving which (#4617) + + **The gap D1 left open.** ADR-0119 D1 made `engine.transaction()` reachable + through the contract, which is the right answer for multi-write atomicity that + fits in one transaction. Migration-class work does not fit: a million-row + backfill cannot hold one write-lock for its duration, `driver-memory`'s + `beginTransaction` deep-clones the entire database (O(db) per begin), + `ObjectQL.transaction()` binds the **default driver only** so a multi-datasource + migration silently commits part of its work outside it, and a process **killed** + — as distinct from a thrown error — defeats in-process rollback entirely. So the + unit of atomicity is the _chunk_, and durability across chunks is a journal. + + Four consumers had each converged on the same four moves — dry-run preflight, + undo journal, LIFO compensation, re-entrant forward recovery (ADR-0105 D13 + promotion, ADR-0117 D8's ownership backfill, the org lifecycle transitions, and + D10 master-data distribution #4585). One copy is engineering; four is platform + debt, and the fourth author would have had to rediscover the invariant below + from scratch. + + **New: `runMigrationJournal` (`@objectstack/core`).** Preflight runs every + step's read-only validator before any step writes, so a plan that would fail at + step 3 has not written step 1. Rows are chunked per the `bulk-write.ts` + discipline; each chunk's writes run inside `engine.transaction()`. On failure, + committed chunks are compensated newest-first, each in its own transaction. On + restart, a rediscovered run resumes forward from the first chunk lacking + `chunk_done`, or unwinds, per the plan's `onCrash` policy. Forward and + compensate callbacks receive an `attempt` counter; `attempt > 1` means the prior + outcome is UNKNOWN and the callback must recheck by natural key before + re-writing — the same at-least-once contract `bulk-write.ts` already documents, + reused rather than re-derived. + + **The invariant that carries the design:** `chunk_done(i)` is written **inside** + the chunk's own transaction, so `done ⇔ committed` holds by construction; + `chunk_started(i)` is written autonomously **before** it. That asymmetry is what + gives `started ∧ ¬done` exactly one meaning — _the outcome is unknown_ — which + is the only state a crash can leave and the only state recovery reasons about. + Making both writes symmetric would look tidier and would destroy recovery. + + **New: `sys_migration_journal` (`@objectstack/platform-objects`).** Rows keyed + `(run_id, seq)` under a unique index, so a resumed run that miscomputes its next + sequence fails loudly rather than double-recording an event. Registered + unconditionally alongside `sys_migration` because recovery must be discoverable + with **zero host wiring** — a journal some kernels compose and others do not is + a journal a boot scanner cannot rely on (ADR-0078). Distinct in grain from + `sys_migration`, which holds one durable verdict per named migration; this holds + many rows per _run_. Read-only over the API; writes go through the runner in + system context. + + **The runner refuses rather than degrades**, in four places: the runtime cannot + roll back; any preflight fails; the plan declares `onCrash: 'compensate'` but a + step cannot compensate; or a resume's plan hash disagrees with the journal + (resuming a changed plan would apply chunk boundaries the journal never + described). A compensation failure halts and is journalled — never swallowed — + and the run ends `failed`, not `compensated`, because a database in a state no + clean story covers must not be reported as a tidy rollback. + + **`engineCanRollBack` is now shared.** The two-level probe (engine method AND + default-driver `beginTransaction`) was the same condition written twice — here + and in `batchData`'s atomic gate. It now lives in `@objectstack/core` and + `@objectstack/metadata-protocol` imports it, as a type predicate so callers do + not each re-narrow the optional member by hand. Two copies of "can this runtime + actually roll back?" drift by one clause and leave one caller believing it has + atomicity it does not have. + + Boot reconciliation and `os migrate resume` land separately; `findInterruptedRuns` + is the discovery primitive they will consume, and is exported here. + + **Docs:** ADR-0118 (plugin-reachable transactions) is renumbered **ADR-0119**. + It merged one day after an unrelated ADR-0118 (非用户 actor 的平台契约) and the + earlier merge holds the number; citations of "ADR-0118 D1/D2/D3/D4" written + before 2026-08-03 mean the renumbered record. + +- 98877c9: feat(spec,metadata-protocol): `IObjectQLEngine.transaction` joins the slot contract, and `batchData`'s `atomic` flag becomes real — rollback or refusal, never silent best-effort (ADR-0119 D1/D4, #4612) + + **D1 — the contract fix.** `ObjectQL.transaction()` — ADR-0034's ambient + transaction, shipped since v8.0.0 — was reachable from plugin space only + through `as unknown as` casts: the metadata protocol's atomic publish and its + `transactionalBatch` discovery probe, and the sys-metadata repository's + `withTxn`, each declared a private structural slice of an engine none of them + import. It is now declared on `IObjectQLEngine`, required per that contract's + own rule, with its caveats written into the TSDoc as part of the declared + meaning rather than left to be discovered: it covers the **default driver + only**, and when that driver has no `beginTransaction` the callback runs with + no transaction and no rollback. `MetadataHostEngine` and the sys-metadata + repository's engine surface now type their optional member as + `IObjectQLEngine['transaction']`, so a narrow host surface can no longer drift + from the real signature. Runtime `typeof === 'function'` probes stay — that is + test-double defence the type system does not replace. + + **D4 — the honesty fix.** `batchData`'s `options.atomic` promised "rollback + entire batch on any failure (transaction mode)" and delivered a `break` + statement. Every write before the failure stayed committed, and — the part that + did the real damage — the response reported those rows `success: true` under + the one flag whose job is to guarantee they were undone. + + Now an explicitly atomic batch runs inside ONE `engine.transaction()`: the + first failure rolls back every prior write, and the response says so + (`succeeded: 0`, with rows marked `ROLLED_BACK:` / the causal error / + `NOT_ATTEMPTED:`, and no row reporting success). On a runtime that cannot roll + back — no `transaction()`, or a default driver without `beginTransaction` — an + atomic request is **refused** with `501 NOT_IMPLEMENTED` rather than silently + degrading, matching the cross-object `/batch` route. `atomic` takes precedence + over `continueOnError`, whose own description already scoped it to + `atomic=false`. In atomic mode the upsert path no longer falls back to an + insert when its update throws: inside an aborted transaction that fallback can + only fail with a secondary error that buries the real cause. + + **Aligned declaration.** `BatchOptionsSchema.atomic` declared `.default(true)` + while no enforcement site delivered atomicity — and the REST route forwards the + original request body rather than the parsed output, so the declared default + never reached the loop at all. The default is now `false`: the declaration is + aligned down to what every site already does, rather than up to what none of + them did. Honouring the old `true` would have silently flipped the failure + semantics of every existing batch caller and hard-failed ordinary batches on + any driver that cannot transact. Callers who were explicitly sending + `atomic: true` now get what they always asked for; callers sending nothing keep + today's behaviour exactly. + + If you were passing `atomic: true` and relying on partial results surviving a + failure, that was the bug — switch to `atomic: false` (or omit it) for + best-effort semantics. + + ADR-0119 also rules on two items landing separately: D2 specifies a + framework-owned migration-journal runner for multi-step migrations too large + for one transaction, and D3 retires the declared-but-unimplemented + `IDataEngine.batch?`. + +- e6b1b69: feat(spec): allow the aggregate bulk dispatch key `_selectedIds` through the action param gate (objectui#3139) + + A list view's `bulkActionDefs` entry can now opt into an aggregate single-call + dispatch (`execution: 'aggregate'`, objectui 17.1): the renderer invokes the + named object action ONCE for the whole selection, injecting every selected + record id as `params._selectedIds: string[]`, so a single call can produce one + aggregate artifact (zip of QR codes, merged PDF, batch print job). + + `ACTION_PARAM_BUILTIN_KEYS` gains `'_selectedIds'` so the ADR-0104 strict + param gate does not 400 an aggregate dispatch against an action that declares + params — like `recordId`/`objectName`, the key is dispatcher-injected and can + never be authored as a declared param. Pure widening: actions declaring no + params were never validated, and no authored bag legitimately carried this + key. The `bulkActionDefs` describe now documents the aggregate contract + (server reads `params._selectedIds`, results are all-or-nothing, `batchSize` + does not apply, set `maxRecords` for expensive aggregates, and toolbar + url/api actions can interpolate `${ctx.selection.ids}`). + + The showcase's Task → Bulk Actions view carries the specimen: + `showcase_recalc_selection` dispatches the recalc endpoint once for the whole + selection via the endpoint's new `_selectedIds` batch branch, next to the + per-record fan-out fixtures. + +- 2826d1e: fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) + + A flow paused at an `approval` node, a deploy, then an approver clicking + Approve: the request row flipped to `approved`, the UI toasted success — and + the flow never moved. No next-stage request, no error, the record's mirrored + status frozen mid-workflow. Approval flows pause for days by design, so a + restart mid-flight is the normal case: every release could quietly zombify + every in-flight approval, with the approvers none the wiser. + + Durable suspended runs (#1518) had shipped and were not the missing piece. Two + other things were. + + **The wiring could enable a store over a table nobody had created.** Object + registration and store activation resolve different services in different + phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin + declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`, + warned, and continued; `start()` then attached the DB-backed store anyway. Every + suspend failed with `no such table: sys_automation_run` into a log line nobody + read, pauses silently stayed in memory, and the next restart lost them all. + Now: `AutomationServicePlugin` declares `optionalDependencies: +['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an + engine-less kernel must still boot); a registration missed at `init()` is + retried at `start()`, which still lands before ObjectQL's schema sync; the + store is never attached when registration did not happen, and says so at + **error** level instead of warning; the table is probed once at boot so a + broken setup surfaces there rather than one failed write at a time; and a + failed durable write of a paused run is logged at error — it is data loss in + waiting, not a warning. + + **A reported resume failure read as success.** `AutomationEngine.resume()` + answers a lost run by _returning_ `{ success: false }`, never by throwing. + `ApprovalService` discarded that return value, and `decide()` counted only a + thrown error as failure — so a decision against a dead run came back + `resumed: true`, HTTP 200. Resume failures are now classified + (`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining + `PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is + distinguishable from a store that is merely unreachable, and the raw resume + route maps them to 404 / 503 / 409. + + Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which + reads the suspension store, unlike `getRun()`, and throws rather than answering + `false` when the store is unreadable — pre-flights every flow-advancing + operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the + zombie half-state is never created rather than merely reported: the decision + fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A + resume that fails after the decision is durable can no longer be undone, but it + now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of + reporting success. A concurrent duplicate resume stays benign — the engine's + idempotency guard is doing its job — and reports through the new optional + `resumeError` field. Recall and revise-window cancellation stay non-fatal by + design (they abandon the request), but log at error with the reason instead of + swallowing it. Compositions with no automation engine attached are unaffected. + + Existing zombie requests from affected deployments (already `approved`, run + stranded) are not repaired by this change — `releaseDeadRunRequests` only + sweeps requests that are still `pending`. + +- 20b1a9e: fix(data): the audit anchor is engine-owned, and a lookup must resolve (#4447, #4441) + + Two write-path contract holes from the v17 verification sweep. + + **#4447 — `created_at` was client-writable on an ordinary PATCH.** Its two + siblings only looked protected: the audit hook force-advances `updated_at` / + `updated_by` on every update, so a forged value is overwritten. `created_at` is + insert-only, so nothing overwrote it. The root cause is a _declared_ audit + field shadowing the platform's: `applySystemFields` skips its injection when + the object already carries the name, and the merge lets the declared one win — + correct for an authored business field, wrong for the audit family. A built app + artifact ships a materialized `created_at` carrying only FieldSchema defaults + (`readonly: false`), which shadowed the engine-owned definition, so the + readonly strip had nothing to key off. The audit family's **governance** + (`readonly` / `system` / `type` / `reference`) is now forced by the platform + while presentation (label, description, hidden, group …) stays the author's. + Back-dating is unaffected: `preserveAudit` (#3479/#3493) and `isSystem` writes + still reinstate the original timeline. The strip now also reports through + `droppedFields`, giving the #3794 contract its first live producer on this axis. + + **#4441 — a `lookup` accepted an id that exists in no row of its target.** + Including `sys_position_permission_set.permission_set_id`, where a dangling row + is a security-surface record that resolves to nothing and the audience-anchor + gate has to resolve that very set to evaluate the grant. Writes are now refused + with `400 VALIDATION_FAILED` and a `fields[]` entry + (`code: 'reference_not_found'`, naming the field, the target and the + unresolvable id) — the catalogued `FieldErrorCode` that had no emitter until + now, with its message in the four platform locales. + + Scope for #4441 is deliberately narrow: caller-supplied keys only (so server + stamps are never reported as the caller's bad reference), non-system writes only + (seed replay and package install keep their ordering freedom), empty means "no + link", and it fails OPEN when the target cannot be checked. The existence probe + is unscoped, because existence is a fact about the database — whether the caller + may create the binding stays the RBAC/RLS layer's decision. + +- ac37fc6: feat(spec): register `ROLLED_BACK` / `NOT_ATTEMPTED` batch-row error codes; record the batch-row shape migration (#4793) + + Support for the `@objectstack/metadata-protocol` v17 batch-row migration + (#4793 — see its major changeset for the wire change itself): + + - `ERROR_CODE_LEDGER` registers two codes under `@objectstack/metadata-protocol`: + `ROLLED_BACK` (atomic data-batch row was written, then undone by the batch + rollback) and `NOT_ATTEMPTED` (row never ran — an earlier row's failure + aborted the batch). They are the structured, `ApiError.code`-level form of + the message-string prefixes #4620 introduced; `ApiErrorSchema.code` now + accepts them and clients branch on the code instead of regexing messages. + - The ADR-0087 migration registry gains the protocol-17 semantic entry + `batch-row-result-schema-shape` (a RESPONSE surface — nothing stored to + rewrite, so it is a documented TODO for readers of the legacy `row.error` / + `row.record` keys), and `docs/protocol-upgrade-guide.md` is regenerated + with it. + - `BatchOptionsSchema.atomic` / `BatchOperationResultSchema.errors` describe + strings now document the code-based rollback marking (reference docs + regenerated). + + No schema _shape_ changes: `BatchOperationResultSchema` already declared + `errors` / `data` / `index` — the runtime caught up to it. + +- 4820f55: A blueprint `formula` field can finally say what it computes: `BlueprintFieldSchema` and its OpenAI-strict mirror both gain `expression`. + + `BlueprintFieldSchema.type` is the **full** `FieldType` enum, so the AI-build design step could always NAME a `formula` field — but neither the lenient schema nor the strict mirror the model generates against had any key for the body. There was no way, anywhere on that surface, to state what the formula computed. It materialized bare, and cloud's graph-lint then correctly reported `formula_without_expression` with the fix _"Set field expression to a CEL formula"_ — a fix the agent could not write in the blueprint it was holding. Detected, but unfixable on the surface that produced it. + + This is the exact hole `summaryOperations` closed for roll-ups in cloud#970 (see this file's own test: _"z.object STRIPS unknown keys, so before this slot existed a blueprint that correctly declared `{ type:'summary', summaryOperations:{…} }` lost the config at the parse waist and materialized runtime-dead"_). `formula` was simply left behind — the same defect, one field type over. + + It bites hardest through `nameField`, whose own guidance tells the model to point at a formula for numbered entities (invoice/ticket) that compose `number · name`. Without an expression slot, following that advice produces a record title that is blank on every card, lookup chip and breadcrumb. + + **The pin matters more than the key.** A1's root cause is not a forgotten property — it is that two schemas describe the same shape and nothing forced them to agree. The mirror is what the model may EMIT; the lenient schema is what downstream READS. Drift in either direction silently drops authored config. A new test asserts the two field schemas carry **exactly** the same keys, so the next key added to one cannot go missing from the other. + + Cloud's `objectBody` carries the value through to materialization (companion change in the `cloud` repo); it reads the key via cast, as it already does for `defaultValue`, so it is inert against an older spec and live as soon as this ships. + +- 462d9c4: feat(spec): a solution blueprint can declare a roll-up, including a conditional one + + `BlueprintFieldSchema` gains `summaryOperations` (object / function / field / + relationshipField + a predicate), in both the lenient schema and the strict + structured-output mirror. Without a slot for it, a blueprint `summary` field + could only ever be proposed as a bare shell: `z.object` strips unknown keys, so + a blueprint that correctly declared `{ type:'summary', summaryOperations:{ +object:'task', function:'count', filter:{ status:'completed' } } }` lost that + config at the parse waist and materialized runtime-dead — and the design step, + the one place the aggregation is actually known, had nowhere to put it. + + It has to be declarable at design time: the engine recomputes a roll-up only + when a CHILD row is written, so operations added after a build's sample data + loaded leave every parent value empty until someone edits a child. + + Strict mode cannot represent the canonical `filter` map (open-ended + `additionalProperties`), so the predicate is a flat `conditions` array of + `{field, op, value}` — the shape a dashboard widget's `condition` already uses; + the lenient schema also accepts a real `filter` map for a hand-authored + blueprint. `BlueprintWidgetConditionSchema` is now an alias of the shared + `BlueprintConditionSchema`. + + Also makes the lenient schema's top-level `summary` optional. It is a purely + descriptive one-liner with no structural role, but it is what `apply_blueprint` + parses the model's re-emitted blueprint against — omitting it rejected the whole + build with `path: "summary"`, which an agent read as "the summary FIELDS are + invalid" and repaired by deleting the roll-up fields. The strict design contract + still requires it. + +- f2445c9: feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) + + A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, + which are contracted to resolve an affected row COUNT and nothing else. That + satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / + `changes`, so before #4626 the engine fabricated a per-record event with + `recordId: ''` and `after: ` — an event every schema-compliant consumer + must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into + a real delivery naming an unidentifiable record. #4626 removed the fabrication + and published nothing instead: honest, but it left webhooks, knowledge sync and + `subscribeData` silent for every predicate write. + + Bulk writes now get their **own** contract rather than impersonating a + per-record one or going dark: + + - **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from + `DataEvent`, not a widened one: a consumer that receives + `data.records.updated` knows from the type alone that no `recordId` is + coming, instead of discovering an empty string at runtime. + - **Engine** publishes it from the `multi: true` branches of `update()` / + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A + predicate that matched **zero** rows publishes nothing (no data changed — this + is what keeps an idle background sweep from becoming an hourly "0 records" + delivery), and a driver that resolves a non-count publishes nothing and warns + rather than asserting a number it cannot verify. Per-record writes are + untouched, including a scalar `where.id` with `multi: true`, which is still a + single-record target and still emits `data.record.deleted`. + - **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are + **not** extra sources for `create` / `update` / `delete`: the delivered body + has no `recordId` and no record, so routing it to existing per-record + subscribers would hand them a payload missing every field they read — the + same class of breakage as the old `recordId: ''`, from the other direction. A + webhook that wants both subscribes to both. Bulk deliveries dedup on the + producer's event uuid, since two sweeps in the same millisecond are genuinely + different events that a timestamp-based key would collapse. + - **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the + same loud boundary validation as `subscribeData`. Kept a separate method for + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => +void` callback would recreate exactly the "typed field, `undefined` at + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened + from `data.` to `data.record.`, so an aggregate event is ignored rather than + rejected as off-contract. + - **Knowledge sync** now says out loud that a predicate write leaves its index + stale. A knowledge index is a per-record projection and `matched: 40` names no + record, so no event shape could drive it — the durable fix is reconciliation, + tracked in #4672. + + The event carries no `where` predicate. The only one available at publish time + is the middleware-composed AST, whose filter embeds the security layer's + injected row scoping (RLS, sharing) — publishing it would ship tenant scoping + internals to whatever external URL a webhook points at. + + Also pays off a measurement debt from #4655, which claimed the write-path cost + of event publishing had been measured but never published the numbers: + `packages/objectql/src/engine-data-events.bench.ts` measures it. Against an + in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, + single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless + of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its + relative cost shrinks as the match set grows. + +- 5b843fb: fix(automation,spec): the cold-boot flow bind must survive the read path's own annotations (cloud#971) + + `getMetaItems({ type: 'flow' })` decorates every served item with + `_diagnostics` (and `_draft` on a preview read). The cold-boot bind fed that + served document straight into `engine.registerFlow` → `FlowSchema.parse`, and + since #4001 closed the metadata schemas an unrecognized key **throws** instead + of being dropped — so every flow failed to register on every boot with + `unrecognized_keys: ["_diagnostics"]`. Not fatal only by luck: the + record-change plugin binds record flows a second way, so automations kept + firing behind one WARN per flow. A flow whose only binding path is this one + would have gone silently dead. + + Fixed at the read seam (`readFlowDefsFromProtocol`), not by loosening + `FlowSchema`: the payload is malformed because we decorated it, so the + producer's annotation is the producer's to remove. + + `@objectstack/spec` gains `METADATA_READ_DECORATIONS` / `stripReadDecorations` + (`kernel/metadata-read-decorations`) — the list moves out of + `metadata-protocol`, where it was module-private, so the producer and its + cross-layer consumers share one definition. `metadata-protocol` re-exports + `stripReadDecorations` unchanged; no public surface is removed. + +- 67bf2e2: The dashboard's header, filter bar and root reject unknown keys — the posture its widget was rescued from three releases ago. + + `DashboardWidgetSchema` has been `.strict()` since the ADR-0021 cutover, and its error map states the reason in its own words: undeclared keys "were dropped silently before strict validation, shipping inert metadata". Everything _around_ the widget kept the posture the widget was rescued from — the header, the header actions, the global filters and their option sources, the date range, and the dashboard root itself. + + Closed with `strictObject`, so each now names its surface, echoes the offending key, and suggests the nearest declared one. The aliases are the vocabulary of a dashboard: `charts`/`components`/`cards`/`tiles` → `widgets`, `filters` → `globalFilters`, `refresh`/`autoRefresh`/`pollInterval` → `refreshInterval`, `dateFilter`/`timeRange` → `dateRange`. + + Three wrong-layer keys get a prescription rather than a rename, because a rename would be wrong: + + - **`layout` on the dashboard.** It reads like a template selector; there isn't one. Each _widget_ carries `layout: { x, y, w, h }`, and a widget with none is auto-flowed into the grid. + - **`subtitle` on the header.** The header renders the dashboard's own `label`/`description` — there is no separate header copy, only `showTitle`/`showDescription` to toggle them. + - **`filterBindings` on a global filter.** The binding runs the other way: a _widget_ maps this filter's `name` to one of its own fields, or `false` to opt out. + + Deliberately left open: `DashboardWidgetOptionsSchema` stays `passthrough`. It is the renderer-extras escape hatch by design — presentation settings the renderer understands are none of the spec's business — and the four keys in it that _do_ reach the analytics query are already declared explicitly (framework#3588). Closing it would break the escape hatch to fix a problem that was already fixed the right way. + + Also unchanged: the widget's bespoke `strictWidgetAnalyticsError`, which carries the pre-ADR-0021 inline-analytics and objectui-internal prescriptions. It works and is tested; converging it onto `strictObject` (which would add "did you mean" suggestions on top of those prescriptions) is a follow-up, not a prerequisite. + + Registered types closed at the top level: **23 of 25**. Still open: `action`, `view` — and they are the last two, so the unknown-key _warning_ layer is down to two covered roots. When both close it has nothing left to warn about at a root, which is the campaign finishing rather than the layer breaking; the test says so in place rather than being deleted. + + One thing surfaced while re-pointing a test at `view`: a single unknown key on a view reports **twice**, because `view` is a union (container | ViewItem | overlay) and the walk emits one finding per strip-mode variant the key lands in. Recorded in the test rather than dodged by picking a non-union collection; it becomes moot when `view` closes. + + Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working dashboard changes. + +- 6117f7b: fix(spec,service-analytics): a percentage measure carries its SCALE, so a ratio of 1 is 100% (objectui#3136) + + A `%` format string says how to PRINT a number, not what scale that number is + on — and the two readings collide at exactly `1`, which is both "100%" (a 0–1 + ratio at full compliance) and "1%" (a single percentage point). With nothing on + the wire to tell them apart, renderers guessed from the value's magnitude and + resolved the collision the wrong way: an SLA / pass-rate dashboard reporting + `sla_rate = 1` displayed **"1.0%"** — "everything met the SLA" read as "1% met + the SLA" — on both the KPI card and the dataset table. + + The scale was never actually unknowable; it just never left the server. A + measure declaring `derived: { op: 'ratio' }` is a 0–1 fraction _by definition_, + and a measure aggregating a `percent` field has whatever scale that field + stores. Both facts sit in metadata the enrichment pass already reads for the + ADR-0053 currency chain — which walks back to the source field, checks + `type === 'currency'`, and rides the resolved code onto the result column. + Percentages got no such treatment. They do now, through the same seam. + + **`percentScaleOf(field)` (`@objectstack/spec/data`)** is the one place the + question is answered. A `percent` field stores a FRACTION unless it declares + `max > 1` (e.g. `min: 0, max: 100`), which marks whole-percent storage — the + same rule the percent edit widget already writes by, so a value round-trips. + Non-`percent` fields get no opinion: a plain `number` an author formatted with + a `%` keeps meaning exactly what their format string says. + + **`AnalyticsResult.fields[].percentScale`** carries the answer: `'fraction'` + (`1` ⇒ "100%") or `'whole'` (`1` ⇒ "1%"), absent when the column is not a + percentage. `queryDataset` sets it from the measure's `derived.op === 'ratio'` + first, then the source field's scale. `currency` — emitted since ADR-0053 but + only ever written through a cast — is now declared on the same interface. + + The config seam `measureCurrency` is renamed **`sourceFieldMeta`** and returns + `max` alongside `type`/`defaultCurrency`. The old name had already outgrown + itself: the date-bucketing path reads `type` through it to tell a `date` + dimension from a `datetime` one, and the percent chain is its third consumer. + + Renderers that receive `percentScale` must scale by it rather than inferring + from the value; one that does not receive it (an older server) keeps whatever + fallback it has, so this is additive on the wire. + + **Same widget family, second fix: an empty filtered group is a measured zero.** + A measure-scoped filter can exclude every row of a group the grid still lists, + and the database reports that by omitting the group from the supplementary + result — after the merge, indistinguishable from "not measured". For a COUNT or + a SUM it _is_ measured: the answer is 0. `emptyGroupValueFor(aggregate)` + (`spec/data/aggregation-policy`) states which aggregates have an identity over + the empty set, and `queryDataset` fills it in once all supplementary merges are + done (a later measure's merge can append rows no earlier query saw). So + "0 of 12 paid" now reports `0` instead of blank, and a ratio built on it + computes to `0` instead of going null — the difference between a dashboard + saying "0% met the SLA" and saying nothing at all. `avg`/`min`/`max` keep their + null: there is nothing to average over an empty group, and flattening that to + zero would invent a measurement. + +- cdf4d9a: `datasource.config` is now validated against its driver's contract (#4410) + + `config` was the one authorable slot on a datasource with no gate at all. The + schema's own comment claimed "the driver's own `configSchema` is what validates + it" — nothing did: both bundled driver specs set `configSchema: {}`, no code read + the field, and the per-driver zod schemas were not even exported from the + package. So `config: { hostname: 'db.internal' }` (the key is `host`) was + accepted in silence and the datasource connected to `localhost` while the parse, + the save and the connection probe all reported success. + + `DatasourceSchema` now parses `config` against + the contract for the declared driver, and `DatasourceAdminService` + (create/update/test, the Setup wizard's path) applies the same check. Both read + one registry in `@objectstack/spec/data`, which also projects each contract to + JSON Schema for `DriverDefinitionSchema.configSchema` and the Studio connection + form, so the form offers exactly the fields the validator accepts. + + New exports from `@objectstack/spec/data`: `PostgresConfigSchema`, + `MysqlConfigSchema`, `SqliteConfigSchema`, `SqliteWasmConfigSchema`, + `MongoConfigSchema`, `MemoryConfigSchema`, plus `resolveDriverId`, + `getDriverConfigSchema`, `getDriverConfigJsonSchemaById` and + `validateDriverConfig`. A driver the platform ships no contract for (a plugin's + `com.vendor.snowflake`) keeps an unvalidated `config`. + + **Migration.** A config that was silently ignored now fails with the correction + in the message. The renames: + + | Wrote | Write instead | Driver | + | ---------------------------- | ------------- | ---------------------- | + | `user` | `username` | postgres, mysql, mongo | + | `connectionString` / `dsn` | `url` | postgres, mysql, mongo | + | `uri` | `url` | mongo | + | `file` / `path` / `database` | `filename` | sqlite, sqlite-wasm | + | `hostname` | `host` | postgres, mysql, mongo | + | `searchPath` | `schema` | postgres | + + And the relocations — keys that were never driver config: + + | Wrote in `config` | Write instead | + | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `min` / `max` / `idleTimeoutMillis` / `connectionTimeoutMillis` | the datasource's own `pool` block | + | `schemaMode` | next to `driver`, on the datasource | + | `readOnly` | `external: { allowWrites: false }` — the enforced write gate. (This row said `capabilities: { readOnly: true }` until #4487's liveness audit found that key has no reader.) | + | `ssl: { ca, cert, key, rejectUnauthorized }` | the datasource's own `ssl` block — inside `config`, `ssl` is the on/off boolean shorthand | + + Two memory-driver keys are **removed**: `indexes` and `maxRecordsPerObject`. + `InMemoryDriverConfig` has no field for either — the driver keeps no indexes and + evicts nothing — so both were inert. Drop them; for real indexing use a driver + that indexes. + + A postgres, mysql or mongo datasource must now name a connection target + (`database`, or a `url` that carries it). An empty `config` used to mean "the + client's own localhost default", which is the same defect in its most complete + form. + + **Also fixed, because the contract can only be enforced where it is honoured.** + These keys were declared and read by nothing; they now reach the driver: + + - `datasource.pool` is honoured by every SQL driver (it was declared, carried + into the connection spec, then overwritten with a hardcoded `{ min: 0, max: 5 }`), + and maps onto the Mongo client's `minPoolSize` / `maxPoolSize`. + - `datasource.schemaMode` reaches the driver. It was dropped between the + datasource record and the connection spec, so a `schemaMode: 'external'` + database — one ObjectStack must never run DDL against — was constructed as + `managed`. + - `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at + the record — nothing put it on the connection spec — so a TLS block configured + nothing, which is exactly what its own schema comment warns about ("a TLS + setting that never took effect looked identical to one that did"). + - postgres `schema` (knex `searchPath`), `applicationName` and `statementTimeout`. + - mongo `password`, `authSource` and `options`. A mongo datasource carrying a + `config.password` previously composed its URL with an **empty** password. + +- aee1806: feat(spec,service-datasource): graduate the driver factory's four legacy `datasource.config` `??` fallbacks into an ADR-0087 conversion (#4456) + + `createDefaultDatasourceDriverFactory` still carried four undeclared read-side + `??` fallbacks that predate the #4410 config gate: sqlite `file`/`database` + (canonical `filename`), postgres/mysql `connectionString` (canonical `url`), + postgres/mysql/mongo `user` (canonical `username`), and mongo `uri` (canonical + `url`). They were never part of the contract — no schema, form, doc or example + ever named them — and they kept working only because the reader was lenient + (AGENTS.md Prime Directive #12 debt). + + **FROM → TO, applied automatically at load** by the new conversion entry + `datasource-config-driver-key-aliases` (retired-from-load-path; replayed over + stored `sys_metadata` rows by `applyConversionsToStoredItem` and by + `os migrate meta`): + + - sqlite / sqlite-wasm: `config.file` / `config.database` → `config.filename` + - postgres / mysql: `config.connectionString` → `config.url`, `config.user` → `config.username` + - mongo: `config.uri` → `config.url`, `config.user` → `config.username` + + The mapping is driver-aware — `database` renames only under sqlite, where it + aliased the file path; for postgres/mysql/mongo it is a canonical key and is + untouched. A canonical key already present wins; the legacy alias is left + shadowed (the factory's `??` precedence, preserved). + + **Behaviour change (the deletion):** the factory now reads exactly one spelling + per key. A `DatasourceConnectionSpec` handed to the factory _directly_ with a + legacy spelling is no longer honoured — authored metadata was already rejected + by the per-driver zod gate with a rename hint (#4410), and stored runtime + datasource rows are canonicalized at every rehydration seam (including the + `sys_metadata` restore path in `DatasourceAdminServicePlugin`, which now + replays the full conversion chain), so no supported path still produces the + legacy shape. One-line fix for hand-built specs: use the canonical key from + the table above. + +- 5293114: fix(automation): a decision's three declared ways to route a branch are now one working model (#4414) + + A `decision` node advertised three mechanisms for splitting a path and only one + of them did anything. The other two were the ADR-0049 `declared ≠ enforced` + shape, and the pair of them shipped a guard that does not guard in + `examples/app-crm`. + + | mechanism | before | now | + | :--------------------------------------------------- | :----------------------------------------------------------------------------------------------------- | :---------------------------------------------------- | + | `edge.condition` | ✅ the only one that worked | unchanged | + | `edge.isDefault` | **zero readers** anywhere but the schema declaration | BPMN default flow, enforced in `traverseNext` | + | `decision.config.conditions[].label` → `branchLabel` | matched **0** out-edge labels across every example app, then fell back to the full edge set in silence | routes; an unclaimable label is logged, not swallowed | + + ## What was broken, end to end + + `crm_convert_lead_wizard` means "already converted → abort screen; otherwise → + the wizard". It ran **both**: an already-converted lead got + "This lead has already been converted" and then walked straight into the + conversion wizard behind it. Four independent silences stacked up: + + 1. the decision's first condition was authored `{lead_record.status} == +'converted'` — braces in a slot declared bare CEL, so it was string-compared + and never true; + 2. the second (`'true'`) therefore won, yielding `branchLabel: 'No — proceed'`; + 3. no out-edge carried that label (they were `'Yes'` / `'No'`), so traversal + discarded the branch and considered every out-edge; + 4. `e3b` was unconditional, so it ran regardless — and the natural fix, marking + it `isDefault: true`, was a dead key. + + ## The model + + `branchLabel` narrows the edge set → `condition` gates each edge → `isDefault` + catches whatever is left. Concretely: + + - **`isDefault` is enforced.** A default edge is traversed only when no + conditional sibling of the same source node matched, and it is no longer part + of the unconditional parallel fan-out — that distinction is the whole point of + the marker. Passed over because a real branch won, its target records the same + `skipped` step a closed gate does (#4354). + - **An unclaimable branch label warns.** Traversal still falls back to the full + edge set (a run mid-flight must not die on a metadata error) but says so, + naming the computed branch and the out-edge labels that exist. + - **A decision that declares no `conditions` reports no branch.** It used to + report `'default'` unconditionally — a label no out-edge in the repo ever + carried — which is why every decision node fell back to the full edge set. + The `'default'` sentinel survives for the case it actually describes (declared + conditions, none matched) and is now claimed by the `isDefault` edge as well + as by an edge literally labelled `'default'`. + - **`conditions[].expression` is evaluated as the bare CEL it is declared to + be.** The raw string went to the legacy `{var}` template path, where + `lead.status == 'converted'` cannot resolve and the branch is decided by + string comparison. Unlike `edge.condition` this slot carries no + `ExpressionInput` envelope — the decision descriptor is deliberately + schemaless — so the executor supplies the dialect. A brace-in-CEL predicate + now fails loudly (ADR-0032 §1c) instead of deciding `false`. + + ## Caught at authoring time too + + Four new `os build` / `os validate` warnings, because a wrong route is silent at + run time by nature (Prime Directive #12): + + `flow-branch-label-unmatched` (the shipped shape), + `flow-decision-unconditional-branch` (a guarded decision with an unconditional + sibling — the actual hole), `flow-default-edge-with-condition` and + `flow-multiple-default-edges`. + + Both of the first two fire on the pre-fix `convert-lead.flow.ts` and are silent + after it. + + ## Effect on flows that already exist + + Enforcing `isDefault` changes how a **stored** flow behaves, and the flows it + changes are mostly Studio's own. `objectui`'s flow edge inspector has always + written `isDefault: true` when you bind an out-edge to a decision's default/else + branch — into a key with zero readers, so that edge ran unconditionally, in + parallel with whichever branch actually matched. Those flows now take exactly + one branch. That is the fix, but it is a behaviour change on existing data + rather than only on newly authored metadata, so it is worth knowing before + upgrading: a flow that quietly ran two paths will now run one. + + Nothing changes for an edge that never carried the marker — `isDefault` defaults + to `false`, and an ordinary unconditional out-edge still fans out in parallel + exactly as before. + + ## The example app + + `crm_convert_lead_wizard`'s guard is now a plain exclusive gateway: the + redundant `config.conditions` is gone and `e3b` carries `isDefault: true`. One + mechanism per decision, and exactly one branch runs. + + Verified: 11 new engine/executor tests (including the reported repro in both + directions), 12 new linter tests; `@objectstack/service-automation` 577 tests + and `@objectstack/cli` 652 tests green, all three example apps build with no new + findings. + +- 5966c2a: feat(spec): declare `doc.tags`, so a book group's `include: { tag }` can finally match something (#4509) + + `BookGroup.include` has always accepted two shapes — a glob over doc names, or + `{ tag: '' }`. The tag variant could never match a single doc in any stack, + and not because the matcher was missing. Everything downstream already existed: + + - `matchesInclude` compares `doc.tags` against the rule (`book.zod.ts`) + - the book route already forwards `tags: d.tags` into the resolver (`rest-server.ts`) + - `ResolverDoc` already declares `tags?: string[]` — annotated `(P3d; absent today)` + + The gap was one line at the _authoring_ end: `DocSchema` is `.strict()` and had + no `tags` key, so writing `tags:` on a doc was a parse error. Every doc therefore + reached the resolver with `tags === undefined`, and the variant matched nothing, + forever. + + This is the enforce half of ADR-0049 enforce-or-remove. Removal was the + alternative and was rejected on two grounds: a union member has no clean + tombstone (`retiredKey` covers object keys), so authors would have received a + bare union error carrying no prescription — and it would have discarded a + working matcher to fix a declaration. + + ```ts + defineDoc({ name: "crm_guide_lead", content: "# Leads", tags: ["tutorial"] }); + defineBook({ + name: "crm", + groups: [{ key: "tut", label: "Tutorials", include: { tag: "tutorial" } }], + }); + ``` + + Prefer a name convention (`include: 'crm_guide_*'`) where one exists — tags earn + their place when membership cuts _across_ naming, e.g. a `tutorial` tag spanning + several feature prefixes, which no glob can collect. + + Additive: `DocSchema` previously rejected `tags`, so nothing that parsed before + parses differently now. + +- ac471a0: **BREAKING**: `IAutomationService.getSuspendedScreen(runId)` is now **async** — it returns `Promise` instead of `ScreenSpec | null` (#4515). + + FROM → TO for anyone calling or implementing it: + + ```ts + // caller + - const screen = automationService.getSuspendedScreen(runId); + + const screen = await automationService.getSuspendedScreen(runId); + + // implementer + - getSuspendedScreen(runId: string): ScreenSpec | null + + async getSuspendedScreen(runId: string): Promise + ``` + + One-line fix: `await` the call (the enclosing function is almost certainly already `async`), and make any test double resolve rather than return (`mockResolvedValue`, not `mockReturnValue`). + + Why it had to change: the method could only ever read the engine's in-memory hot cache, because a synchronous signature cannot consult the durable suspended-run store. `SuspendedRun.screen` _is_ persisted (`sys_automation_run.screen_json`) and `resume()` cold-reads it back, so after a process restart a still-suspended screen run could be resumed (`POST …/runs/:runId/resume` → 200) while `GET …/runs/:runId/screen` returned 404 “No pending screen for run” — the refresh-safe re-fetch failing in exactly the situation it exists for (page refresh, another device), and the rendering half of ADR-0019's durable-suspend promise missing while the resuming half shipped. + + `AutomationEngine.getSuspendedScreen` now takes the hot cache as its fast path and falls through to the store via the same loader `resume()` rehydrates from. A run that does not exist, is no longer suspended, or paused at a non-screen node still resolves to `null`, so `GET …/runs/:runId/screen` keeps returning 404 for genuinely absent runs. No sync variant of the method remains on the contract. + +- 07a4e26: `field` rejects unknown keys, reusing the curated table that already knew which advice would be wrong. + + `FieldSchema` carries more silently-stripped keys than any other shape in the spec, and it said so about itself for two releases. Two separate notes on the object — one on `accept`/`maxSize`, one on the five pruned governance keys — both state that a write "parsed clean and the key was silently stripped", and both name it the ADR-0104 failure class. Neither could do anything about it, because the object was not `.strict()`. This is the fix those comments wanted. + + **The guidance is derived, not hand-written, and the reason is a bug the first attempt shipped.** `FIELD_KEY_GUIDANCE` in `data/authoring-key-lint.ts` is twenty-odd curated entries for exactly this surface — every one found in the wild, already held honest by a test asserting each `to` names a key `FieldSchema` really declares. A hand-written table beside it would be a second copy of the truth, and it immediately proved why that matters: the lint's table suppresses the suggestion for `pii` **because `pii` is three edits from `min`**. A bare edit-distance suggester answers a personally-identifiable-information key with _"did you mean `min`?"_ — confident, wrong, about an unrelated concept. The hand-written pass did exactly that. `FieldSchema` now reads the table directly (`to` → alias, `why` → guidance). + + Note what moved: the table is unchanged and still tested. Its _consumer_ changed — the lint no longer reaches `field` now that the parse rejects first, so the same curation that powered a warning now powers a rejection. That is the intended end state for every entry in it. + + Among what it carries, the two that matter most are the ones that read as protection and were not: `encryptionConfig` and `maskingRule` were pruned in 2026-06 because they "implied at-rest protection that never happened". An author who declared either had their field stored in plaintext exactly as if they had not, and heard nothing. The rejection now points at `type: 'secret'` and at `requiredPermissions` (ADR-0066 D3, enforced by the FieldMasker). + + **A cycle the whole test suite passed through.** `shared/suggestions.zod` imports `FieldType` from `data/field.zod`, so adopting `strictObject` here closed a loop — field → strict-object → suggestions → field. Under `OS_EAGER_SCHEMAS=1` (how `build-schemas.ts` runs) every `lazySchema` body executes at module init, so the loader hit a half-initialized module and threw before a single schema was built. **284 test files and 7,239 cases went green over it**; tests import lazily, so the cycle never resolved in the order that breaks. Only the eager build caught it. + + `strictObject` now defers its error map to first use, which costs nothing and makes the helper cycle-proof for every schema after this one rather than making each conversion prove it is not in a loop. The property is pinned via an observable — an alias-table getter that fires exactly when the map is built — and verified to go red when the map is hoisted back to construction. + + `field` also gains its ADR-0010 protection envelope. It was the one type the original envelope probe actually checked (the other 24 took an early return), so it was the only gap anyone could see for as long as that probe was green — and it outlasted every gap the probe was hiding. **The undeclared-envelope debt list is down to one** (`action`), from eight. + + `SelectOptionSchema`, `CurrencyConfigSchema` and the nested shapes under `FieldSchema` (lookup columns, lookup filters, `dependsOn` entries, roll-up summaries) close alongside it. Left open deliberately: `AddressSchema`, `LocationCoordinatesSchema` and `CurrencyValueSchema` are runtime _value_ shapes with no consumers at all, two already marked for removal — not authoring surfaces, so strictness is not the question they raise. + + Registered types closed at the top level: **22 of 25**. Still open: `action`, `dashboard`, `view`. + + Authoring impact: a key `FieldSchema` does not declare is now rejected instead of silently discarded — it was already being ignored, so no working field changes. + +- eb4204b: feat(automation): a `script` node's purity contract is declared, and a function that writes can say so (#4396) + + The `script` executor's contract — _the named function returns a value; data I/O + stays on the flow graph_ — existed only as a comment inside the executor, while + #4354's run summary depended on it. That summary reports no record metrics for a + `script` step precisely because a pure function's writes are downstream + `create_record` / `update_record` nodes counting themselves. A function that + wrote anyway made its run report `selected: 30, acted: 0` — indistinguishable + from the broken sweep the counters exist to detect, recorded permanently on + `sys_automation_run`. + + **The rule is now visible.** `ActionDescriptor` carries + `handlerContract: 'none' | 'pure'`, and the `script` descriptor publishes + `'pure'`, so the action catalog, the designer palette and the reference docs + state the rule an author has to follow instead of an executor holding it + privately. + + **And a legitimate writer can opt out honestly.** A `defineStack({ functions })` + entry may declare what it does, in either shape: + + ```ts + defineStack({ + functions: { + scoreLead: (ctx) => ({ score: 42 }), // pure — the default + syncBilling: { handler: syncBilling, effect: "writes" }, // declared writer + }, + }); + ``` + + A step calling a declared writer reports `unmeasuredEffect`, so the run's + `unmeasured` tally keeps the broken-sweep query + (`selected > 0 AND acted = 0 AND unmeasured = 0`) off that flow — and only that + flow. Marking _every_ `script` step unmeasured was rejected: it would blind the + detector on every flow that calls any function in order to cover the few that + break the rule. + + Nothing here is retired or renamed: a bare `functions: { fn }` entry is + unchanged and means `effect: 'pure'`. The declaration is carried end to end — + `ObjectQL.registerFunction` accepts `{ packageId, effect }` alongside the + existing `packageId` string and exposes `resolveFunctionEntry(name)`, + `objectstack build` lowers a declared entry without dropping it, and the + artifact loader re-attaches the module's callable to the declaration the JSON + carried. + + **Also fixed:** `bindHooksToEngine` returned before registering a bundle's + functions when the stack declared no hooks, so a flow-only app's + `defineStack({ functions })` reached the engine as nothing and every `script` + node calling one failed with "no function named 'x' is registered". + +- 4f13be2: Liveness coverage is complete: the nine remaining registered metadata types are + governed (#4488) — `app`, `book`, `doc`, `email_template`, `job`, `mapping`, + `seed`, `translation`, `validation` — and `PENDING_GOVERNANCE` is empty. Every + type in the metadata-type registry now has a ledger with per-property verdicts, + evidence, and a `verifiedAt` stamp. + + Spec: + + - Nine new ledgers under `packages/spec/liveness/` (≈150 verdicts). Highlights: + the ENTIRE `email_template` authoring surface is dead (nothing materializes + metadata items into the `sys_email_template` rows `sendTemplate` reads — an + admin editing the password-reset mail in Studio changes nothing; #4509); + `app.areas[].visible` / `areas[].requiredPermissions` are fail-open dead + gates (item-level siblings ARE enforced); `translation.validationMessages` + is read by nothing while #3778's own migration table steers authors into it; + `job`/`validation` have runtime-authoring doors disconnected from their + execution points (#4509). `doc` and `seed` are fully live. + - `check-liveness.mts`: the walker now sees through `z.preprocess` pipes + (takes the OUT side when the IN side is a transform) — `translation`'s + registered schema was unwalkable before this. + - `liveness/README.md`: the per-type count table's method is now decided and + recorded (it mirrors `check-liveness.mts --json` `byStatus`, the number CI + enforces); all rows regenerated from one run, and the two-generations-stale + `webhook` row rewritten to the post-#3489/#3494 state. + + CLI: + + - `lint-liveness-properties` registers the six newly governed types that carry + `authorWarn` entries (`apps`, `books`, `jobs`, `emailTemplates`, `mappings`, + `translations`), so authors hear about the misleading keys at compile time. + +- 7cf42fe: `mapping`, `agent` and `page` reject unknown keys — and `strictObject` stops suggesting keys that were removed. + + **A bug this campaign introduced into its own helper, fixed first.** `skill` closed in the last batch while still carrying `retiredKey` tombstones, and `strictObject` built its "did you mean" candidates from the whole shape — tombstones included. So a `triggerPhrase` typo was answered with _"Did you mean `triggerPhrases`?"_, a key that had been **removed**. An author who complied landed on the tombstone and got a second rejection telling them to delete what they had just been told to write. + + Third occurrence of a shape the ledger already records twice — this campaign's fix pointing the way into the failure it exists to kill — and the first one in a _shared_ helper, where it would have reached every conversion after it. Fixed structurally: **never suggest a key the schema cannot accept.** Candidates that accept `never` are dropped, so the rule holds without knowing why a key is unwritable. The two helpers stay complementary; `retiredKey` is _stronger_ than a `guidance` entry, because typing the key as `never` also fails `tsc` when the config arrives through a variable, where excess-property checking would not fire. + + **`agent` had two security-shaped removals with no tombstone.** `visibility` and `tenantId` were deleted as unenforced security properties — correctly, since neither did anything — but deleted without a prescription, because the shape was `.strip` and there was no rejection to attach one to. An author who wrote `visibility: 'private'` believed the agent was hidden. It was listed to everyone, and always had been. This is the `skill.permissions` class: a key that reads as a security control, is not one, and says nothing when you write it. Closing the shape created the channel, so both now name what actually gates an agent (`access` / `permissions`, enforced at the chat route since #1884). + + **`route` on a page was a fiction the platform's own test suite carried.** `stack.test.ts` authored `route: '/landing'` for years. `PageSchema` has never declared it — a page is routed by its `name`, which in the map format under test IS the map key, which the test asserted six lines below the key contradicting it. Fifth test found codifying a strip-era fiction as intent, and the most likely to be reinvented, since `route` is the first key anyone reaches for on a page. Tombstoned with `path` and `url`. + + Also tombstoned from each file's own comments, now that there is somewhere to put them: `agent.memory.shortTerm` (declared a working-memory window nothing consumed — ADR-0013 D3), `page.recordReview` and `page.blankLayout` (page types with no renderer, removed in framework#2265), and wrong-layer pointers for the page keys that read like real controls — `interfaceConfig.visualization` (the display mode is chosen from `appearance.allowedVisualizations`, and is not a page type), `guardrails.allowedTopics` (there is no allow-list, only `blockedTopics`). + + `mapping` and `page` also gain their ADR-0010 protection envelope, which their loaders stamp and their schemas could not hold. **The undeclared-envelope debt list is down to two** (`action`, `field`), from eight. + + Registered types closed at the top level: **21 of 25**. Still open: `action`, `dashboard`, `field`, `view`. + + That count is now derived and pinned rather than tallied by hand — it had already drifted by one, in a campaign whose recurring lesson is that hand-maintained measurements of coverage go stale. `metadata-type-schemas.test.ts` walks each registered schema for its top-level catchall and carries the open list as a reverse pin, so closing a type fails the test until the list shrinks, exactly like the envelope debt list next to it. + + The unknown-key warning layer's covered roots drop from 6 to 3 — verified as a hand-off rather than a hole: `agent.zzz`, `page.zzz` and the nested `page.regions[0].zzz` are each now rejected by the parse. A broken walk and a successful graduation shrink that count identically, so the check is pinned in the test alongside the number. + + Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working metadata changes. + +- f78dd83: fix(metadata,client): `subscribeMetadata` callbacks receive real `MetadataEvent`s — the producer now fulfils the declared contract (#4602) + + `@objectstack/spec/api`'s `MetadataEvent` declares top-level `id` (uuid, + required), `metadataType`, `name`, `definition?`, `userId?` — and after + #4587's convergence it is the **only** declared contract for realtime + metadata-change events. But the producer (`MetadataManager`) published a raw + `RealtimeEventPayload` envelope with everything nested under `payload` and no + `id`/`userId`, while the client SDK force-cast that envelope into the callback + (`callback(event as any as MetadataEvent)`). Subscribers who wrote + `event.name` / `event.metadataType` — exactly what the types promised — + compiled green and read `undefined` at runtime. + + Producer now fulfils the contract: + + - `MetadataManager.register()` / `unregister()` build a true `MetadataEvent` + (generated uuid `id`, flattened top-level fields, `userId` when the write + declares an actor) and validate it with `MetadataEventSchema.parse` before + publishing. The transport envelope is unchanged (`RealtimeEventPayload`, + with `payload` carrying the complete `MetadataEvent`). + - A `register()` **overwrite now publishes `metadata.{type}.updated`** instead + of a second `.created`, mirroring the existing `added`/`changed` watcher + split. Previously `.updated` was declared with no producer at all. + - `MetadataEventType` is a closed enum: metadata types outside it (e.g. + `translation`) have no declared realtime event, so nothing is published for + them (debug-logged) instead of emitting an event every schema-compliant + consumer must reject. + + Consumer validates instead of casting: + + - `@objectstack/client`'s `subscribeMetadata` (and therefore + `@objectstack/client-react`'s metadata hooks, which delegate to it) unwraps + the envelope and runs `MetadataEventSchema.safeParse` at the boundary. An + off-contract payload is rejected loudly (handler error, callback never + invoked) — never coerced or passed through. The `as any as MetadataEvent` + double-cast is gone. + + New seam: `MetadataWriteOptions.userId` (`@objectstack/spec/contracts`) lets + write paths that know the acting user carry it into the published event's + `userId`. Existing callers are unaffected — the field is optional and absence + means "no human actor". + +- 7bba90b: `ObjectSchema` rejects unknown top-level keys on the PARSE path, not only in `create()` — closing the founding example of #4001, which had been live for the whole time #1535 was considered fixed. + + **The gap.** #1535 built the unknown-key guard as a hand-rolled check inside the `ObjectSchema.create()` factory, on the reasoning that authored `*.object.ts` modules call `create()`. They do — but they are not the only producer, and not the path most instances travel. `defineStack({ objects })`, `/api/v1/meta/types/object` and the Studio form all reach the schema through `parse()` / `safeParse()`, which kept stripping unknown keys in silence: + + ``` + ObjectSchema.parse({ …, workflows: ['x'] }) → key silently discarded + ObjectSchema.create({ …, workflows: ['x'] }) → rejected since #1535 + ``` + + Object-level `workflows: [...]` — the example this campaign was filed on, an author believing they had wired up automation and shipping dead metadata — was still reproducible on the main path. + + The base shape is now `.strict()` with the `UNKNOWN_KEY_GUIDANCE` tombstones and the semantic renames the warning layer already knew (`capabilities` / `features` → `enable`), so graduating from warn to reject costs an author no prescription. `create()` is unaffected: its own check runs before parsing and throws a richer located error. Safe on the read path for the same reason the other closed registered types are — the ADR-0010 envelope is declared, and `stripReadDecorations` removes `_diagnostics` / `_draft` before any strict re-parse. Verified rather than assumed: every `ObjectSchema.create()` call across `platform-objects` and the three example apps uses only declared top-level keys. + + **A new tombstone.** `namespace` (retired in ADR-0006 D4) had none, so it was stripped in silence — an object written as `{ namespace: 'sys', name: 'user' }` shipped as plain `user`, under a name its author never intended. The rejection now carries the fix (`name: "sys_user"`). + + **And a coverage regression this change would otherwise have introduced.** The unknown-key warning layer gated each metadata collection on its ROOT schema's posture, so closing `object` at the root switched off the warnings for everything _beneath_ it too — its 71 nested strip-mode sites would have stopped reporting in the same change, with nothing to say so. Posture is a per-node property and the walk now treats it as one: a strict root stays silent at its own level (the parse owns that failure) while the descent continues. Nested `object.fields.*` warnings are unaffected by the graduation. + + Three tests that asserted the strip as correct behaviour are now rejection tests — `namespace`, the retired `compactLayout` alias, and the removed `detail` block. The `compactLayout` one had pinned the author-hostile outcome explicitly: "the retired key is STRIPPED, not aliased — an old-key author gets no highlightFields rather than silently working." + +- ebb209c: fix(spec,lint): withdraw the `record:*` blocks from the react tier — no renderer read the props it published (#4413) + + The react-tier contract published `objectName` / `recordId` on + ``, ``, `` and + ``, and no renderer read either prop. All ten `record:*` renderers + take their record from `useRecordContext()`, which only the record route + (`RecordDetailView`) and the metadata editor's preview (`PagePreview`) ever + mount; the `kind:'react'` page renderer wraps the page in a + `SchemaRendererProvider` alone. So the blocks rendered their "bind a record to + preview" placeholder — or, for `record:related_list` (the one that does read + `schema.objectName`), refused to fetch because the parent id never arrived. A + page authored exactly to contract came back EMPTY with nothing reported + anywhere, including by `os validate`, which resolved those props' field names + against the object they named: lint standing guard over a binding that never + ran. + + Withdrawn rather than implemented. The contract was not merely unimplemented, + it was the wrong SHAPE: per-block bindings describe four independent fetches of + one record, which is exactly the coupling the shared record context exists to + prevent (`record:details` drops the fields a mounted `record:highlights` + registered; one inline-edit save bar commits them all under a single + `ifMatch`). Honoring the props would have fossilized that (Prime Directive + #12). The naming of that primitive — a record SCOPE an author wraps around the + family, one fetch, shared context — is the open design question, filed as #4444. + + `@objectstack/spec` drops the four blocks from `REACT_BLOCKS` and gains the + ledger for why, plus the working replacement per type. The family is derived + from `ComponentPropsMap`, so a record component added later is gated the day it + lands — including the six that were never in the contract but are just as + reachable through the registry-built react scope. + + `@objectstack/lint` gains `react-block-needs-record-context` (error), which + rejects them on a react page by tag and through `` + alike, quoting the block that does work: `', '=', +parentId]}>` for a related list, `` for a + field panel. A locally-declared component of the same name shadows the injected + scope and is left alone. + +- 2a44c1d: Six more registered metadata types reject unknown keys — `report`, `dataset`, `email_template`, `skill`, `job`, `book` — and `skill`'s silently-stripped `permissions` key now says where the real gate lives. + + Mechanical work on the registered-type line, using `strictObject`. Each conversion is one call plus the aliases that fit that surface's vocabulary (`sections`/`chapters`/`toc` → `groups` on a book, `cron`/`interval` → `schedule` on a job, `title`/`content`/`html` → `subject`/`body` on an email template). + + **One of them was a silent permission gate, which is the class this campaign cares most about.** `skill` accepted a `permissions` key and dropped it — skill invocation was never permission-gated. An author who wrote it believed they had restricted who could invoke the skill, and had not. A test even pinned that strip as correct behaviour, with a comment explaining the right answer (gate at the AGENT via `access` / `permissions`, enforced since #1884) — but that comment was only visible to someone reading the test file, never to the author who got it wrong. The rejection now carries the prescription, and the test asserts the rejection. + + Same shape as `visibleWhen` → `visible` in #3746: the most valuable entry in an alias table is rarely a typo, it is a key that reads as a security control and silently is not one. + + Registered types closed at the top level: **16 of 25**, up from 9 when this line started. Still open: `action`, `agent`, `dashboard`, `field`, `mapping`, `page`, `translation`, `view`. + + The unknown-key warning layer's covered population drops from 12 roots to 6 as a result, which is the campaign succeeding rather than coverage rotting — the parse takes over where the lint used to warn. Nested strip sites under a closed root still report, unchanged. + +- 0848bea: feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + + **FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: + rename the value. Nothing else about the object changes. `os migrate meta --from 16` + rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry + `object-managed-by-system-to-system-data`, never silently reinterpreted. + + ADR-0103 split the overloaded `system` bucket in v16, and it split it + **additively**: the 20 engine-owned objects moved to the new explicit + `engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables + (`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), + `sys_user_preference`, `sys_approval_delegation`, and the three messaging config + grids — stayed behind on `system`. That was the right move for a v16 that could + not break authors, but it left the enum in a state where the surviving value + names the half that had already moved out: `system` sitting on precisely the + objects a user writes. + + That is not a cosmetic complaint. An author choosing between `system` and + `engine-owned` had nothing in the vocabulary to choose _on_, so the bucket was + re-overloadable by anyone reading the name in good faith — a model author most + of all, since "system table" reads as "the engine owns this" in every other + codebase. `system-data` states both boundaries explicitly: the **schema** is the + platform's (versus `platform`, which is tenant-modelled), the **data** is the + admin's or the user's (versus `engine-owned`, where the engine owns both). + + Because v16 already drained the engine side, the conversion is a **one-to-one + mechanical value rename** with no judgement call — by construction every + remaining `system` declaration is writable platform data. + + **One deliberate consequence — the affordance default flips.** `system` defaulted + LOCKED and each of the 8 objects re-opened its writes with a + `userActions: { create: true, edit: true, delete: true }` block. `system-data` + defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data + is yours" should not make every member ask for it back. Those blocks are now + redundant and have been deleted from the 8 platform objects; keep `userActions` + only to **NARROW**. If you converted an object that carried no `userActions`, it + gains the generic affordances — the honest reading of the bucket it moved into. + + **No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS + and permission sets all adjudicate off resolved affordances and the principal, + never off the bucket name. `system-data` simply joins `platform` / `config` as a + bucket the fail-closed guard does not cover, because a writable default has + nothing to close on. The 8 objects passed that guard before (via `userActions`) + and pass it now (via the bucket default), for the same resolved-affordance + reason. + + `'system'` is **retired from the load path**: the enum rejects it with a + prescription naming `system-data` and the one-line fix. Absorbing it silently at + load would leave every author still writing the name this rename exists to + unteach. + +- f3141d8: fix(spec): a node that publishes no descriptor configSchema can now own an expression-ledger entry (#4439) + + `FLOW_NODE_EXPRESSION_PATHS` is the #4027 ledger that tells `registerFlow` and + `objectstack validate` which config keys hold expressions, and in which dialect. + Its ratchet (`config-expression-ledger.test.ts`) derives what it expects from + descriptor `configSchema` `xExpression` markers, and fails in **both** + directions — an undeclared marker, or a ledger entry nothing declares. + + `decision` / `script` / `subflow` publish **no** descriptor `configSchema` on + purpose: a published partial schema would drop the editors their hand-written + Studio forms need (the #4210 incident), so their contract lives in + `schemaless-node-config.zod.ts`. Those two rules compose into a hole — an + expression slot on a schemaless node is structurally unreachable by the ratchet, + and because the reverse direction rejects unclaimed entries, it cannot be + entered by hand either. + + `decision.conditions[].expression` sat in that hole. Its own schema says + _"Bare CEL predicate deciding this branch"_ and its own comment names `{…}` as + the #1491 trap, and no validator walked it — so `{lead_record.status} == +'converted'` passed `tsc`, passed `objectstack validate`, passed registration. + #4414 made that fail loudly at run time; this makes it fail at build time, + which is the delay #4027 exists to remove. + + ## The fix + + The ratchet now reads **both** declaration channels: + + - **descriptor `configSchema`** — unchanged, enumerated from the live registry; + - **`schemaless-node-config.zod.ts`** — the marker rides + `.meta({ xExpression })` through `z.toJSONSchema`, the same channel + `loop.collection` has used since objectui#2670. + + Spec hands the second channel over as JSON Schema + (`getSchemalessNodeConfigJsonSchemas()`, memoized, `input` mode — the shape a + descriptor's `configSchema` already is), so the ratchet walks both with the + _same_ function. No second notion of "a declared expression property", which is + the duplication a ledger exists to remove, and no `zod` dependency added to + `service-automation`. Each channel is separately asserted non-empty, so a broken + derivation on one side cannot hide behind the other's results. + + `SCHEMALESS_NODE_CONFIG_SCHEMAS` is also exported for anything else that needs + to reason about all node config contracts. Additive — objectui's + `flow-node-config` reconciliation imports each schema by name and is unaffected. + + ## The sweep + + The other schemaless slots were checked and deliberately carry no marker: + `script.template` is a template **id**, not a body; `script.inputs` / + `script.variables` / `subflow.input` are values that interpolate `{token}` — + text-with-holes, the shape essentially every node config string has, already + covered generically by `validate-flow-template-paths` and the CLI flow linter. + A `flow-template` ledger entry means something narrower: a _reference that must + resolve to a value_, like `loop.collection`. So `decision.conditions[] +.expression` is the only genuinely declared expression slot on the class — now + recorded in the ledger's header so it is not re-derived. + + ## Docs corrected + + The flows guide taught the **wrong dialect** for decision predicates in three + places (`'{order_amount} > 10000'`), plus a "braces missing in a decision + expression" warning that inverted after #4414 — and `FlowNodeSchema`'s own + `@example` did the same. All corrected to bare CEL, with the history stated so + an author with a braced predicate knows what changed and why their build now + fails. The dialect table drops from three dialects to two: predicates never take + braces, values always do. + + Verified: 13 new/updated tests across the ratchet, the engine's registration + pass and `@objectstack/lint` (including the exact app-crm predicate rejected at + both `registerFlow` and `objectstack validate`); `pnpm build`, `pnpm typecheck` + (122 tasks), `pnpm lint` and `check:docs` clean. + +- e5e7ee0: `strictObject` makes closing an authoring shape one call; `seed` and `doc` are the first two registered metadata types converted with it; and a new invariant test found two live protection-envelope bugs on `hook` and `datasource`. + + **The helper.** The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]` array, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. That was 34 key arrays and 16 probe files with most of the authorable surface still ahead — and the array was never necessary: `knownKeys` feeds only the edit-distance suggestion, and the shape object is right there at the call site. `strictObject({ surface, history, aliases?, guidance? }, shape)` derives it, which also removes the per-schema drift probe: a key list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay **optional** — they carry judgement rather than transcription, and treating curation as a precondition is part of why the ratchet moved slowly. + + **A sharper targeting rule.** The five-directory triage answers "is this authorable?" but not "is this parsed?" — and after #4410 that second question decides whether a flip enforces anything. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness at all; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things. + + **Two live bugs, found by a check rather than by reading.** `MetadataPlugin`'s artifact loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc`). A new invariant test over the registered-type registry found it twice more on its first run: **`hook` and `datasource` had both gone strict in the #4001 data step without the envelope.** Both now declare it. The test asserts the hard case (rejects) unconditionally with no exemption list, and tracks the quieter case (silently strips, currently only `field`) separately. + + **Ledger.** `strictObject` replaces the old wiring recipe as the standard, and the gate's site-counting method now counts `strictObject(` alongside `z.object(` — counting only the latter would have made every conversion look like surface disappearing, so "solved" and "deleted" would read the same. The gate caught that itself on the first conversion. + + Authoring impact: on `seed` and `doc`, a key the schema never declared is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names the surface, echoes the key and suggests the closest declared one (`rows` → `records`, `body` → `content`), with tombstones for `path` / `slug` on `doc`. The published JSON Schema is unchanged: `build-schemas.ts` converts with `io: 'output'`, which already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with a known envelope gap; it is a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call. + +- 800bdb0: The Studio authoring surface rejects unknown keys — plugin manifests, the flow builder, and the object designer. + + All 27 shapes across `studio/` close. These are the configs a Studio extension author writes by hand, and a dropped key here is quiet in the way this campaign cares about: the plugin loads, the canvas renders, the designer opens — each contributing less than its author declared. A viewer that never appears in the switcher looks like a registration bug, not a spelling one. + + **The plugin manifest gets the guidance that matters, because this file invites the mistake itself.** It says outright that the manifest is "the `package.json` equivalent" and that `contributes` is "analogous to VS Code's". That analogy is the point _and_ the hazard: an author who knows VS Code reaches for its vocabulary, and every near-miss is a word that is correct over there. `displayName` → `name`, `publisher` → `author`, `contributions` → `contributes`, `activation` → `activationEvents`; and for the keys with no counterpart at all — `main`, `engines`, `categories`, `keywords`, `repository`, `icon`, `dependencies` — a sentence saying what to use instead. + + `main` is the one worth calling out. An author declares an entry point, gets a plugin that loads and contributes nothing, and it looks exactly like a broken `activate()`. The rejection now says there is no entry-point key: contributions are declared in the manifest, runtime components are registered imperatively in `activate()`. + + **The triage verdicts were provisional, and verifying them found one wrong.** All three files carried `(p)` from the original pass, and `plugin.zod.ts` was `mixed` — with an empty note, which is how an unexamined label survives. Reading it settles the question: all eight shapes are contribution points on a hand-written manifest. There is no wire half. + + The method is worth keeping, because the original triage had none: **each file exports a `define*` factory that parses an author-written literal, and a `define*` factory is the authoring door.** That is the same lens the registered-type batches used, and it is cheaper than reasoning about who consumes the output. Recorded in the ledger for the next row that needs promoting out of `(p)`. + + What this checkout could not settle, stated in the ledger rather than glossed: whether `objectui` also _constructs_ these configs programmatically and parses them with extra internal keys. If it does, strictness turns that into a loud 422 at its build — detectable, with the rename suggested — rather than the silent narrowing it replaces. + +- 38f7e4f: Translation bundles and `translation` items reject unknown keys, at both doors — and a bespoke guard that only covered one of them retires into the rejection message. + + Translation data has the cruellest version of the silent-strip failure in the spec. A misspelled group is dropped, the bundle loads without complaint, and the string renders in the source language — **indistinguishable from a translation nobody has written yet.** There is no wrong output to notice, so the bug looks like a coverage gap forever. + + **#3778 already knew this, and fixed it for ten keys.** Retiring the object-first (`o.`) dialect meant old-shape items saved cleanly and resolved to nothing, so it added a `z.preprocess` that scanned for the ten retired keys and raised a 422 naming the right destination for each. + + That guard has the shape every workaround for `.strip` has: + + - **It could only catch mistakes someone had already thought of.** `object` for `objects`, `message` for `messages`, an invented group — still dropped in silence. + - **It ran on one of the two doors.** Only `TranslationItemSchema` (Studio / the metadata API). The same ten keys in a file-authored bundle — the path the examples and the platform apps actually use — were stripped with no complaint at all. The same asymmetry #4522 found in #1535's object guard, two authors solving the problem in front of them. + + With the shape closed the guard is redundant, so it is gone and its ten prescriptions ride the rejection as `guidance`. **What was worth keeping was never the detection — it was the prose.** Detection generalizes for free once the default flips; the sentence telling an author where their content goes does not. + + Closed across every authorable group: object/field/view/action/section translations, apps and navigation, dashboards and widgets, pages, settings, metadata forms, and the i18n config. Aliases carry the near-misses edit distance cannot: `views` → `_views`, `help` → `helpText` on an action param (`help` is correct one surface over), `label` → `title` on a widget (a dashboard's headline is `label`, its widget's is `title` — one level apart, opposite spellings). + + **The i18n config's four removed knobs get tombstones.** #3494 deleted `fileOrganization`, `messageFormat`, `lazyLoad` and `cache` because no runtime read them. Removing a key that was already a no-op leaves the author with the same silence and one more reason for it; the rejection now says which issue removed it and why. + + **Two gates were found doing half their job.** + + `translation` was on the ADR-0010 envelope debt list — the loader stamps `_packageId`/`_provenance` and the schema could not hold them, so `authored-translation-sync` strips them by hand on the read side. Declared; the list is down to four. + + And `metadata-create-seeds.test.ts` — the canonical guard against a designer's create shape drifting from the spec — asserts every seed parses. The `translation` seed ships `{ name, label, locale, objects }` and the type declared neither `name` nor `label`, so **two thirds of the authoritative create shape was being stripped while the gate that exists to catch that reported green.** A gate built on a `.strip` schema catches a missing required key and can never catch an extra undeclared one. `name`/`label` are now declared (`translation` was the only registered type of 25 without a `name`), classified in the liveness ledger as dead _body_ keys with the row column as the live one. + + Registered types closed at the top level: **17 of 25**. Still open: `action`, `agent`, `dashboard`, `field`, `mapping`, `page`, `view`. + + Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working translation changes. Verified against the real bundles in `examples/app-crm`, `examples/app-todo` and `platform-objects`, all of which `.parse()` at module load, and against the live `GET /translations/:locale` body. + +- 97faca3: feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457) + + A selection-bar bulk action was declared as + `z.array(z.record(z.string(), z.any()))` — **no shape at all**. The real + contract lived in objectui's `BulkActionDef` interface and in the executor that + reads it, so every authoring mistake landed as a silent runtime downgrade: + `opeartion` parsed and the executor hit `Unknown operation: undefined` per row; + `excution: 'aggregate'` parsed and the def stayed per-record, so the endpoint + written for ONE `_selectedIds` call got N calls instead — the exact defect + objectui#3139 was filed to make expressible. That is ADR-0018's "second + vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks) + crossed with ADR-0078's silently-inert metadata. + + `ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema` + got in #3746/#4001: a **strict** def whose unknown-key error names the offending + key and the canonical spelling. Beyond spelling, it refuses the combinations the + executor never reads — `patch` outside an `update`, `execution` outside a + `custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a + hand-written `actionDef`, which is attached by the renderer when it resolves the + def's `name` and which authored by hand would smuggle an action definition past + the action registry. + + **One shape that parsed before is now rejected**: `operation: 'custom'` without + `execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly + one authored shape (the aggregate one); every other custom def falls to + `Promise.resolve()` per row — a button that reports success for every selected + record and does nothing. The error names both legal forms: `bulkActions: +['']` for per-record (promoted with the action's own label, params and + `visible`), `execution: 'aggregate'` for one call over the whole selection. + + Two things are deliberately left open: + + - **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a + `[key: string]: unknown` catch-all — widget config (min/max/step/format) + forwarded to the field renderer as-is. Locking it down would reject valid + config, so declared keys are typed and the rest rides through, the same call + `dashboard.zod.ts` makes for a widget's `config`. + - **The bulk-param / action-param spelling divergence** (`help`/`helpText`, + `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which + `ActionParamSchema` has no counterpart for). objectui already owns a converter + for the promoted direction; converging the authored direction is a cross-repo + change with its own migration. Typing them as they are is what makes the + divergence visible rather than undocumented — the prerequisite for closing it. + + `label` and the param/option labels are `z.string()`, not `I18nLabelSchema`: + an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map + on this path) and the bar renders `def.label` as a React child, so blessing the + map form would trade a parse error for a blank screen. Localize by declaring a + real action and naming it in `bulkActions` — that path runs through the i18n + resolver. + + **Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an + `execution: 'aggregate'` entry is a name reference (it is what + `resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id + and resolving it would be nonsense. The walk also reaches an **object's own + `listViews`** for the first time — an object has no top-level `list`, so that + tier had simply never been visited while the view-level ones were covered. And + the hint no longer tells a bulk-surface author to add a `locations` entry: the + selection bar is the one surface that does not filter on it, so naming the + action there is the whole placement. + + Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`. + +- ad5fe25: fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless + + `field.zod` defines `user` as "a lookup specialized to the `sys_user` system + object … target fixed to the `sys_user` system object", and `Field.user()` — + unlike `Field.lookup(reference, …)` — takes no target argument and writes + `reference: 'sys_user'` itself. The target is a constant of the type. + + Two callers read `field.reference` raw and so disagreed: the protocol's expand + gate refused `?expand=` with `400 INVALID_FIELD … declares no +target object`, and objectql's expand loop skipped it. Metadata authored without + the redundant `reference` — hand-written JSON, an AI author, a Studio form — was + read as under-specified when it was complete. Live capture (cloud#983): an + AI-built app's very first screen rendered an error page over that 400. + + New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of + "what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the + set those same two callers already share for "is this a reference at all"). Both + halves of the expand path read it, so the gate can no longer refuse a field the + engine would have expanded, nor bless one it skips. + +- ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) + + Four independent surfaces where the answer a caller received contradicted the + contract the surface declares. All four were found driving a real showcase boot + against `17.0.0-rc.1` and are catalogued in the #4482 rollup. + + - **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox + refusing to run untrusted code that asked for a capability it does not hold, + which is the crash contract's case (#3951), not a deliberate rejection of a + malformed request. It now answers 500, and the `SandboxError:` debug prefix + no longer reaches the client. + + - **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The + write path returned `record: null` / `success: true` for an id that resolves + to nothing, while GET on the same id correctly 404s; `deleteMany` reported + every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller + can no longer read a successful envelope as proof the write landed. + + - **#4436 — the unsupported-filter-operator refusal shipped without + `error.code`.** A refusal with no code is unmatchable by a client, and the + message leaked the internal `[sql-driver]` prefix. It now speaks + `INVALID_FILTER` without the driver prefix. + + - **#4483 — the `$search` auto field set admitted its lead field + unconditionally.** `nameField`/`name`/`title` were prepended without passing + `SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key. + The lead field now only ORDERS the set it is already a member of; it can no + longer admit one. + + These change responses that were observably wrong, so callers coded against the + buggy shapes — a 200 on a missing record, a 400 on a capability denial — will + see different status codes. Graded `minor` on that basis rather than `patch`. + +- 5ef0b5b: The six `validation` rule variants reject unknown keys, each against its own key set, and the type can finally represent its ADR-0010 protection envelope. + + `validation` was the registered metadata type this campaign kept deferring: a `z.lazy()` discriminated union whose six variants each `.extend()` a shared base, so the one-call `strictObject` conversion the other types took does not apply. + + **Why not just close the base.** `.extend()` inherits strictness, so closing `BaseValidationSchema` alone would have rejected unknown keys correctly — but the error map closes over the key list it was _built_ with, which for the base is only the shared keys. A typo of a variant's own key (`transtions` for `transitions`, `formuIa` for `formula`) would have been rejected with no rename offered, which is the difference between a fixable error and a confusing one. The union discriminates on `type`, so an author is always on exactly one variant and that variant's full key set is the right candidate list. The base is now a named shape spread into six `strictObject` calls, each with the aliases that fit its own vocabulary. + + **The envelope.** `validation` is a registered metadata type, so `MetadataPlugin`'s loader stamps `_packageId` / `_provenance` on it and `getMetaItemLayered` → `saveMetaItem` round-trips a body carrying them — and the schema could not represent them, so they were dropped on every parse. Declared once in the shared shape, so all six variants inherit it, and the type comes off the debt list in `kernel/metadata-type-schemas.test.ts` (that list carries a reverse pin, so removing an entry is forced rather than optional). + + Authoring impact: a key none of the variants declares is now rejected instead of silently discarded — it was already being ignored, so no working behavior changes. The rejection names which variant it landed on ("this state-machine validation rule"), echoes the key, and suggests the closest declared one from that variant's full set. + +- 48fbacb: The view surface closes — the container, both view kinds, and the ~28 config shapes under them. This is the last batch of #4001. + + Views are the surface an author iterates on visually, which is exactly why a dropped key hides here: the view still renders, just not the way it was described. `FormFieldBaseSchema`, `FormSectionSchema` and `FormButtonConfigSchema` were closed years ago under ADR-0089 D3a; the other forty-odd shapes in the file kept the posture those three were rescued from. + + **`defineView`'s guard was another one-door workaround.** It rejects a container that defines no views, and its comment says why: "`ViewSchema` strips unknown top-level keys, so a _flat_ list view would parse to an empty container". That is the fifth bespoke guard this campaign has found built around silent stripping, and like all of them it covered exactly one door — `defineView`. Through the metadata door (Studio, the API, an agent) a flat view produced an empty container in silence. The rejection now lives in the parse, so it reaches both, and carries the wrap instruction rather than only the symptom. The guard stays for the case strict cannot see: `defineView({})`, which has no unknown keys and still registers nothing. + + **The container carries its own identity and object binding, and the first draft tombstoned all three.** `name`, `label` and `object` were in that guidance list, telling authors they "belong to a single VIEW, not to the container" — which rejected shapes the platform itself writes: `saveMetaItem` sends the name, artifact-shipped containers (`service-ai/ai_traces`) carry it, the validation sweep injects it, and a stack-level `views: [...]` entry needs `object` to say which object its views belong to (`getViewsByObject()` reads that binding). All three are now declared — `object` as live with its consumer cited, `name`/`label` as dead _body_ keys with the row column live, exactly as `translation` needed in batch 5. + + Caught by the full monorepo suite — `@objectstack/objectql`, then `@objectstack/cli` — never by `packages/spec`. That is the fourth false guidance claim in three batches (`action.permissions`, `action.location`, `view.name`/`label`, `view.object`), and the fix that finally worked was not more care but a different method: **scan every real container payload in the repo and keep only the guidance entries no real payload contradicts.** Six of the nine survived. That check costs one command and should have run before the guidance, not after three CI failures. + + The rule worth carrying: **a rejection's prose is behaviour, not documentation.** It tells an author what to do next, and a confidently wrong one is worse than none, because there is no reason to doubt it. + + **Three shapes are deliberately left open, each with its reason in the file rather than a silent skip:** + + - **`FormSectionSchema`** already closed under ADR-0089 D3a with `strictVisibilityError` and a `.transform()` that normalizes the `visibleWhen`/`visibility` pair. Converting means re-expressing that map as `guidance` and re-proving the transform — a refactor of working, tested behaviour, not a strictness change. + - **`UserFiltersSchema`** deliberately _strips_ `tabs`/`showAllRecords`, which are page-only keys (ADR-0047), with a test asserting the drop. The likely right end state is a rejection saying "tabs are page-only" — but that is a behaviour change with a real consumer question behind it (something may pass a page-shaped block through relying on the strip to narrow it). The campaign's own rule is verify-then-enforce, and this batch did not verify it. Named as the one open shape in the file. + - **The flattened Studio overlay** in `ViewMetadataSchema` must stay open: it carries auxiliary round-trip keys (`isPinned`, `sortOrder`, …) that `saveMetaItem` persists verbatim. + + **That last one is the trap the ledger warned about, arriving on schedule.** `.extend()` inherits strictness, so closing `ListViewSchema`/`FormViewSchema` for authoring silently made the overlay strict too — turning a shape _the platform itself writes_ into a 422. Both members now `.strip()` back, with a comment saying the `.strip()` is load-bearing rather than leftover. + + ## `view` is the end state, not the last item of debt + + The registered `view` schema stays `strip`, and it always will: it is a union of three runtime shapes and a union is only as closed as its most open member. That member is the Studio overlay above — a wire shape wearing the same type name. + + So the campaign's final number is **24 of 25 registered types closed, with the 25th a documented permanent exception**. That is recorded in `metadata-type-schemas.test.ts` beside the reverse pin, so nobody "finishes the job" by force. What closed is everything an author writes; what stayed open is the thing the ledger's classification rule exists to distinguish — arriving here as the campaign's answer rather than as an exception to it. + + ## Where the campaign ends up + + - **Registered types closed: 24 of 25** (from 9 when this line started), the last one exempt with a stated reason. + - **The ADR-0010 undeclared-envelope debt list is empty**, from the eight the structural walk opened it with. + - **The unknown-key warning layer has one covered root left** — `view`, and only its open member. When a layer built to warn about strip-mode metadata has almost nothing left to warn about, that is the ratchet finishing. + + Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working view changes. + +### Patch Changes + +- 430dcc2: fix(runtime,lint): `action.body` binds a handler only for `type: 'script'` (#4352) + + `ActionSchema.body` has always described itself as "Only used when type is + `script`", and its JSDoc went further — "Only meaningful when + `type === 'script'`. When set, the runtime invokes the body inside the sandbox + … and ignores `target`." The runtime read none of it: + `actionBodyRunnerFactory` bound a handler the moment `body` parsed, and + `collectBundleActions` collected any named action. A `type: 'url'` action + carrying a leftover `body` was therefore registered in the action registry and + executed in the sandbox — reachable through + `POST /api/v1/actions/:object/:action` and through + `ql.object(o).execute(name)`, and counted by the governance inventory as a live + handler. + + Declared ≠ enforced, in the shape that is hardest to debug: an author flips + `type` from `script` to `url`, reasonably concludes the body is now dead code, + and it keeps running with nothing anywhere saying so. + + **Behaviour change.** `body` now runs only under `type: 'script'`: + + | Action | Before | After | + | :------------------------------------------------------------- | :-------- | :----------------------------------------------------- | + | `type: 'script'` + `body` | body runs | unchanged — body runs | + | `type` omitted + `body` | body runs | unchanged — body runs (`ActionType.default('script')`) | + | `type: 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'` + `body` | body ran | **no handler is bound**; the refusal is logged | + + Only an action that **explicitly** declares a non-`script` type _and_ carries a + `body` changes behaviour. An omitted `type` still means `script`, because the + collectors walk raw bundle objects — a `strict: false` `defineStack` or a legacy + `manifest.actions[]` never passes through `ActionSchema`, so the schema's own + default has to be applied at the gate rather than assumed to have been applied + already. + + **FROM → TO.** If you have an action whose body you want to keep running, set + `type: 'script'` and move the navigation/dispatch target elsewhere; if you want + the target behaviour, delete the now-inert `body`: + + ```diff + { + name: 'open_portal', + - type: 'url', + + type: 'script', + target: '/portal', + body: { language: 'js', source: "await ctx.api.object('lead').update(…)", capabilities: ['api.write'] }, + } + ``` + + The refusal is **not** silent — silence would only relocate the invisibility the + issue is about. `actionBodyRunnerFactory` logs a warning naming the action, its + declared `type`, and both fixes. + + Authoring-time rejection of the same contradiction already shipped in #4438 + (`ActionSchema` rejects `body` alongside a non-`script` `type`), so what remains + reachable here is data at rest published before that gate existed, plus bundles + that never parsed. This release closes that half. New tests also pin that the + **publish gate resolves to the rejecting schema** — through + `getMetadataTypeSchema('action')` and `ObjectSchema.actions` — so a re-point of + either registration cannot silently reopen the hole while the schema's own unit + tests stay green. + + `@objectstack/lint`'s `validate-action-body-writes` filters by `type` again. + #4344 deliberately made that rule type-blind on the grounds that "the runtime + binds a handler from `action.body` alone … checking what executes beats checking + what the schema says should" — true then, and the comment predicted its own + revision. Execution and declaration are the same set again, so a non-`script` + body no longer produces write-set advice about writes that provably never + happen; the publish gate names that metadata's real defect (`type`) with its own + prescription. + + `collectBundleActions` stays deliberately type-blind: it feeds governance + surfaces that must enumerate every declared action, bound or not, and the other + bind path (`engine.setDefaultActionRunner`, for Studio-authored actions) never + walks it. The gate lives at the single point where a `body` becomes an + executable handler, so there is no second copy of the rule to drift. + +- 5a84d41: fix(approvals): record an admin override of a staffed approver slate AS an override (#4466) + + An admin who is not in a request's `pending_approvers` may still act on it — the + `#3424` privileged-override path exists so a request routed to an unstaffed + position, or to approvers who have all left, is not undecidable forever. The + override is defensible; what was not is what the audit trail recorded. + + `sys_approval_action` had no override column at all. So an admin overriding a + properly-staffed slate wrote a row **byte-for-byte identical** to the designated + approver approving normally: a reader of the timeline saw `approve` by the admin + and could not tell whether the admin _was_ an approver or _overrode_ the ones who + were, and the bypassed approver's later `409 INVALID_STATE` was the only trace — + existing only if they happened to try. The platform knows at decision time (it + took the `isOverrideActor` branch to admit the call at all), so this was dropped + information, not unavailable information. The whole point of an approval record + is to answer "who authorized this, and were they entitled to?". + + `sys_approval_action` now carries **`via_override`** (boolean, optional), set on + exactly the actions admitted by that branch — `decideNode`'s approve/reject and + `reassign`'s admin rescue. It is surfaced on `ApprovalActionRow.via_override` + (`@objectstack/spec/contracts`), returned by `listActions`, and added to the + object's `highlightFields` and two grid list views so a timeline can say + "overrode the approver slate" instead of rendering it as an ordinary approval. + + Three distinctions the column keeps apart deliberately: + + - **`true`** — the actor held no slot in the slate and was admitted only by the + override branch. + - **`false`** — checked, and it was not an override. An admin who _is_ a + designated approver is approving normally and records `false`: the marker is + about which branch admitted the call, not about whether the actor holds admin + rights. + - **absent** — a row written before this column existed. "Not recorded" is not + the same claim as "not an override", so `rowFromAction` maps `null` to + `undefined` rather than to `false`. + + Additive and nullable, so this needs no data migration: existing rows keep + working and simply read as unrecorded. Levelled `patch` rather than `minor` + because nothing an author writes changes — but note it _is_ an observable + behaviour change on a read surface: `listActions` responses and the + `sys_approval_action` grid views now carry a field consumers did not see before, + and `sys_approval_action` gains a column on next schema sync. + +- 203a449: fix(spec): deleted authorable-surface baseline lines must prove themselves (#4650) + + The authorable-surface ratchet's check (a) reads `authorable-surface.json` from + the same commit it is checking, so hand-deleting a baseline line deleted the + very evidence the check runs on — #4638 and #4643 both removed authorable keys + with zero registered conversions and a green gate, and #4662 proved the file + had been hand-edited. `gen:schema` (and `check:authorable-surface`) now anchor + deletions on the baseline at the **merge base with `origin/main`** — the one + version of the file a PR cannot rewrite (comparing against `HEAD:` would be + vacuous in CI, where HEAD is the PR's own commit) — and every deleted line must + carry one of three proofs, all computed inside the gate: + + 1. **Aged-out tombstone** — the base entry was `[RETIRED]` and its surface is + registered in `CONVERSIONS_BY_MAJOR` / `MIGRATIONS_BY_MAJOR` at a major ≥ 2 + behind the current one (the "~two majors" the file's description has always + promised, now enforced). + 2. **Not reachable from the metadata-type roots** (2026-08-02 ruling on #4650) + — BFS over the build's in-memory Zod graph from + `BUILTIN_METADATA_TYPE_SCHEMAS` + the `EXTRA_METADATA_TYPE_SCHEMAS` overlay, + with derived-clone bridging so `.refine()`/`.extend()` copies (e.g. + `ViewSchema` inside `ViewMetadataSchema`) keep their originals protected. + Over-collected entries (REST envelopes and other never-parsed defs) may be + deleted without a tombstone; the exception waives only this file's + requirement and is not a license to change the schema. + 3. **The whole def left the build** — adjudicated by the + `json-schema.manifest.json` ratchet (#2978) and `check:api-surface`. + + `--check` additionally rejects any byte of `authorable-surface.json` that is + not the generator's own output (description/formatting hand-edits included, + per #4662); write mode regenerates such drift. Checks (a0)/(a)/(b) are + unchanged. Build-time gate only — no runtime export, schema shape, or + generated artifact changes. + +- 23338c3: fix(spec): `build-schemas.ts --check` no longer writes `json-schema.manifest.json` (#4711) + + The manifest ratchet had no `CHECK` discriminator. `check:authorable-surface` + (`build-schemas.ts --check`) — one of the eight generated-artifact gates + `check:generated` runs — recomputed the emitted schema set and, on any addition + or renamed-away key, **rewrote the tracked `json-schema.manifest.json` in place + and exited 0**. Two defects, one missing `if`: + + 1. **A check edited the working tree.** Whatever the file held locally was + overwritten by a command whose entire job is to look, which is how a + `git stash pop` / worktree / merge-conflict operation fails for a reason + nobody traces back to a gate. It is also the #4675 merge-driver trap from the + other side: run any check mid-merge and a manifest computed from a + half-merged tree gets committed to disk — "a plausible generated file is an + invisible error". + 2. **The additions branch could never go red in CI.** Seven of the eight + artifacts mean "stale ⇒ fail, run the generator"; this one meant "stale ⇒ + I'll write it for you", inside the same `check:generated` summary. + + The ratchet is now isomorphic to the authorable-surface ratchet immediately + below it: in `--check` it prints the unrecorded keys and the `gen:schema` + remedy, then exits 1; outside `--check` it writes exactly as before. The + `missing` branch (a published schema disappeared) is untouched — it already + exited 1. + + **Behavioural change for contributors:** adding a schema export without running + `pnpm --filter @objectstack/spec gen:schema` now fails `check:authorable-surface` + / `check:generated` instead of being silently repaired. `check:generated --fix` + (and `check:docs`, which runs `gen:schema` first) regenerate it as before, so no + CI job changes shape — a clean checkout with a current manifest stays green. + No published API, schema or authorable key changes. + +- a52e2ef: fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560) + + `Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at + insert time, from the request's `ExecutionContext` — and with no authenticated + user (system / anonymous writes: seed replay, package install, boot + provisioning) `applyFieldDefaults` deliberately leaves the field **unset** + rather than stamp a bogus owner. + + The SQL DDL had never heard of the token. `createColumn` passed any non-object + `defaultValue` straight through to `col.defaultTo(dv)`, so the column was + created as `DEFAULT 'current_user'` and the **database** overrode the engine's + decision: every insert that omitted the field stored the literal string + `current_user` in a `lookup('sys_user')` column — a value that is not any user's + id. `?expand` resolves it to nothing, and on an owner / approver field it is a + silent mis-attribution. Found by #4551's dangling-reference audit on its first + run against a real boot; #4441's referential check could never have caught it, + because it inspects the values a **caller** supplied and here nobody supplied + one. + + **The token vocabulary is now declared once, in `@objectstack/spec/data`** + (`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`, + `isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's + insert-time resolution and the driver's DDL read the same set, which is the + actual defect: `'NOW()'` was special-cased in the branch immediately above for + precisely this reason, and `current_user` — the same convention family — simply + had no entry anywhere the DDL could see. A token added to the set tomorrow is + excluded from literal column DEFAULTs automatically, rather than leaking its own + spelling into the database the way this one did. + + **DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation + and the SQLite table rebuild): + + - `'NOW()'` → the driver-native canonical default, exactly as before; + - any other runtime token → **no column default at all** (the engine owns it); + - Expression envelopes (`{ dialect, source }`) → unchanged, no default; + - a real literal → emitted verbatim, unchanged. + + **Existing databases carry the wrong DEFAULT**, so it is corrected through the + managed schema-drift path (#2186) rather than a bespoke migration: a new + `default_mismatch` finding with a `drop_column_default` op, categorised `safe` + (the statement cannot fail and touches no rows). Dev boots with + `autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported + with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use + `ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place, + goes through the existing table rebuild — which now re-materialises every + column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column + keeps the default it always had instead of losing it to the rebuild. + + **Rows already holding the bogus value are NOT rewritten.** That is #4551's + standing rule — report, never rewrite — so they stay visible to the + dangling-reference audit for operators to resolve deliberately. + +- 20bc357: fix(spec,metadata-protocol,runtime): discovery stops advertising routes for the kernel-internal cache/queue/job slots (#4318) + + The metadata-protocol discovery builder declared `/api/v1/cache`, `/api/v1/queue` + and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no + dispatcher domain, no adapter mount, no plugin registration, and the shipped + providers (`service-cache`/`-queue`/`-job`) are in-process contracts that will + never mount one. Every default boot therefore advertised a route inside the same + `ServiceInfo` whose `handlerReady: false` said the opposite — a single record + contradicting itself (ADR-0076 D12). + + These slots are route-less now, like `realtime` — but unlike `realtime` an + unmarked real implementation stays `available`: the slot's contract is + in-process, so "no HTTP surface" is not reduced capability for it. `handlerReady` + is reported `false` on both discovery builders — for a route-less slot it is not + a proxy for anything, it is the fact itself (the dispatcher used to claim + `handlerReady: true` here for an unmarked occupant, a handler that does not + exist). The explanatory message is written once, as + `inProcessServiceMessage(slot)` in `@objectstack/spec/system`, so the two + builders cannot drift apart. + +- 2382580: fix(docs): the schema-extension FAQ rotted in reverse — `.extend()` works on `FieldSchema` again since protocol 17 + + The #3890 fix taught that `FieldSchema` / `ObjectSchema` / `ActionSchema` are all + `ZodPipe`s and that `FieldSchema.extend` throws. True when written; protocol 17 + (#3855) then retired the deprecated aliases whose lowering was the whole reason + for the field/object transforms, the pipes collapsed to plain `ZodObject`s, and + both prose claims inverted within days: `.extend` works, and the recommended + `FieldSchema.in` is now `undefined` — following the FAQ was once again the only + way to hit an error. Only `ActionSchema` (whose `requiresFeature` → `visible` + lowering is still live) remains a pipe. + + The example gate never noticed because the checked block used only `.parse()` — + deliberately shape-agnostic after CI rejected the first #3890 attempt. That made + the code durable and left the PROSE as the only load-bearing surface, which is + where the rot settled. + + So the rewrite moves the claim into the checked block: it now calls + `FieldSchema.extend({ … })` directly, so if the schema ever grows a transform + again the gate goes red instead of the prose going quietly wrong. Composition + stays as the shape-agnostic default, `ActionSchema` is documented as the pipe + case with the `.in.extend` caveat, and the FAQ teaches the one-line probe + (`typeof SomeSchema.extend === 'function'`) instead of a table of shapes that + history says will not stay true. + +- 7631964: feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, caught by symbol identity (#4446) + + `api-surface.json` records every export per entry point, so a name appearing on + two entries was VISIBLE — but nothing distinguished a re-export (one + declaration, two import paths — fine) from a **dual-source** (each entry + resolving the shared name to its OWN declaration, so which type a consumer gets + depends on nothing but the import path). The dual-source case is the #4411 + trap: spec carried two differently-shaped `MetadataWatchEvent`s plus ten more + pairs, and the copy that _looked_ canonical was the dead one — an auto-import + or model completion picking by name compiled fine and failed later, at an edge + value. + + New pure check `check:dual-source-exports` (lint.yml, after the build step): + + - **Judged by symbol identity, not name.** Every export of all 16 public + entries is resolved through its alias chain to the original symbol; a name + whose entries resolve to ≥2 distinct symbols is dual-source. Name-based + counting would drown the signal — the real surface carries 148 legitimate + re-exported names. + - **Shrink-only baseline** (`dual-source-exports.baseline.json`): the 63 + existing dual-source names are recorded (including the `MetadataFormat` + `./shared`≠`./system` enum divergence, the `./contracts` third-shape + interfaces, and two type-vs-const cases `ShareRecipientType` / + `TransformType`). A NEW dual-source fails the gate with the fix at the + declaration (converge + re-export, or rename); a resolved one fails until its + line is deleted. The baseline is hand-edited under review, deliberately not + generated — a `gen:` would admit new dual-sources via "run the fix command". + - **Self-tests first** (like `check:exported-any`): a fixture proves the + detector still flags a true dual-source (incl. type-vs-const) and still + passes re-exports, so a resolution failure can never read as "clean". + + No runtime code changes; no export changes. The 63 baseline entries are + pre-existing debt, now visible and non-growing. + +- 60ae58e: Reference docs: import examples are now spelled from the package's real export surface + + `build-docs.ts` derived each page's "TypeScript Usage" block from the JSON Schema file + name — the value import verbatim, the `import type` line with a `Schema` suffix stripped — + and nothing verified either name existed. `check:docs` could not catch it: it diffs the + generator's output against the committed docs, so a name the generator invents stays "in + sync" with itself forever. 150 of the committed `import type` names did not compile, and + the `.parse()` example called a type rather than the schema const. + + Both lines are now resolved against `api-surface.json`, the committed record of every + `name (kind)` per entry point: only names the entry really exports are emitted, and the + example parses with the actual schema const. A name that resolves to nothing is dropped + from the page and recorded in the new `docs-import-surface.baseline.json` — a shrink-only + ratchet, so removing a type alias while its schema keeps a reference page now turns + `check:docs` red instead of silently publishing a dead import. + +- ce92674: feat(email): declared email templates reach the mail service (#4509) + + Authoring an `email_template` was a silent no-op. `EmailService.sendTemplate` + resolves `(name, locale)` against **`sys_email_template` rows**, and the only + writers of those rows were the built-in auth templates plus a code-constructed + `EmailServicePluginOptions.templates` that no bootstrapper ever passed. Every + door an author can actually use — a stack's `emailTemplates:`, an + `*.email-template.ts` file, Studio's metadata-admin list, `PUT /meta` — parked + items in a metadata store nothing read back. So an admin could "fix" the + password-reset email in Studio, get a success toast, and watch users keep + receiving the built-in copy: ADR-0078 false compliance on **authentication + mail**. This is the shape #3461 had for webhooks, closed the same way (ADR-0049 + enforce-or-remove, route: enforce). + + **`bootstrapDeclaredEmailTemplates`** now materializes declared templates into + `sys_email_template` at boot. Each item is validated through + `EmailTemplateDefinitionSchema.parse()` — the spec schema finally has a real + consumer, defaults and all — and projected with `mapTemplateToRow`, which is the + **same** mapping the built-in seeder uses, extracted and shared so the two doors + cannot drift apart. A malformed template warns and is skipped rather than + crashing boot. + + **Runtime writes take effect immediately.** Unlike `webhook`, `email_template` + is `allowRuntimeCreate: true`, so a boot-only bridge would have left a Studio + save inert until the next restart — the same bug, half-fixed. The plugin also + subscribes to `email_template` metadata changes and re-materializes the single + changed item; withdrawing a template deactivates its rows (across locales) + rather than deleting them. + + **Three breaks sat on this path, not one**, and closing any two of them would + still have shipped a template that never sent: + + - `@objectstack/objectql` never registered a manifest's `emailTemplates:` into + the metadata registry at all — the key was simply missing from the generic + ingestion list, so the bridge's own source was empty. + - The built-in seeder left `managed_by` at the column's `'admin'` default, which + made platform templates masquerade as admin-authored. Since the bridge refuses + to overwrite admin rows, a built-in would have permanently outranked the + template an app declared. Built-ins now stamp `managed_by: 'platform'`. + - Nothing materialized declared metadata into rows. + + **Seed-not-clobber** mirrors `sys_webhook` (#3489) and `sys_sharing_rule` + (#2909): `sys_email_template` gains `managed_by` / `customized`. Declared + templates re-seed every boot as `managed_by: 'package'`; a row an admin created + (`admin`) or edited (`customized`, stamped by a `beforeUpdate` hook) is never + overwritten, so reworded transactional mail survives redeploys. This is a + separate axis from `is_system`, which keeps its existing meaning for built-ins. + + The `email_template` liveness ledger flips from 13 dead properties to fully + live, with an ADR-0054 runtime proof bound on `subject` + (`email-template-materialization`): it boots a real stack, authors a template + that overrides a built-in auth template, and asserts the **authored** wording is + what reaches the transport. + +- ec975f1: fix(objectql,driver-mongodb)!: `findOne` must say which record it wants, and executes every option it declares (#4419) + + `findOne` reads a single row, which makes its predicate the only thing between + the caller and _an arbitrary record_. When the predicate is missing the result is + not `null` — it is the object's **first row**: a real, plausible-looking record + with nothing to do with the request, which the `if (!row)` check every call site + already has cannot catch, and which then propagates into whatever is computed + next. Reported downstream: line items defaulting their price from the first + product in the catalog rather than the selected one, and "is this deal already + closed?" answered against an unrelated record while the write that followed + correctly targeted the intended id. A throw would have been caught in + development; a `null` would have been caught by the null-check. A valid-looking + wrong record defeats both. + + **Breaking — `findOne` now refuses a query that selects nothing in particular.** + + FROM → TO: + + | Was | Now write | Meaning | + | ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------ | + | `findOne(o)`, `findOne(o, {})`, `findOne(o, { where: {} })` | `findOne(o, { where: … })` | the record matching this predicate | + | | `findOne(o, { search: 'Acme' })` | the record this search finds | + | | `findOne(o, { orderBy: [{ field: 'created_at', order: 'desc' }] })` | the FIRST record in this order — the newest | + | | `find(o, { limit: 1 })` | any row will genuinely do, said at the call site | + + One-line fix: add the `where` you meant, or `orderBy` if you meant "the newest + one", or switch to `find(o, { limit: 1 })` if any row will do. The error names + all four. `find` and `count` are unchanged — returning or counting every row is + an honest answer; only `findOne`'s implicit "just one of them" turns a missing + predicate into a confidently wrong record. The guard reads the CALLER's + predicate, before RLS/sharing middleware injects its own: a tenant filter + narrows which rows are visible, it does not make "whichever comes first" + something the caller asked for. + + **Two silent drops that produced the same wrong record are fixed with it.** + + - **`findOne({ search })` applies the search.** The ADR-0061 `search` → + cross-field `$contains` expansion lived inline in `find` and nowhere else, + while `find` and `findOne` are checked against the SAME legal-key set — so + `search` passed the gate, rode onto the AST, and reached a driver. No driver + reads `ast.search`. The read therefore ran with no predicate at all and + `limit: 1` did the rest. The expansion is now one method both call. + - **`MongoDBDriver.findOne` applies `orderBy`, `fields` and `offset`.** It + translated `query.where` and dropped the rest, so `findOne({ orderBy })` did + not return the newest record — it returned whichever document the scan reached + first. `find` and `_findStream` in the same driver had always handled all + three. This one matters beyond Mongo: the guard above tells an unpredicated + caller to reach for `orderBy`, and an escape hatch one backend ignores is not + an escape hatch. No ordering is IMPOSED when the caller supplies none — both + drivers keep that carve-out (#4363), and `SqlDriver`'s comment about Mongo + "never sorting" is corrected, since it cited the dropped parameter as + agreement. + + **And a gate so the class does not come back.** A drift pin walks + `ENGINE_OPTION_KEY_SETS.findOne` and requires each declared key to have an + observable effect — on the AST the driver receives, on the driver options, or in + an explicit "not executed, and here is why" entry (only `limit`, which the + contract's `limit: 1` overrides). `search` sat declared-but-unexecuted through + two rounds of hardening because nothing asked that question. + + Together with #4346 (`filter` → `where` folds on every entry point) and #4400 + (unknown option keys throw), a read parameter the engine does not execute now + fails at the call site instead of quietly changing the answer. + +- 61cc079: docs(spec): `app.homePageId` 的墓碑说清真正的退役理由 —— 「no shell ever read it」是假的 (#4709) + + **改的是「为什么删」的表述,不是删本身。** `app.homePageId` 在 17.0.0 依旧退役 + (`retiredKey`:编译期 `never`、解析期报错),conversion `app-dead-authoring-keys-removed` + 的行为、baseline、`os migrate meta --from 16` 的处方一字未动。 + + #4667 给出的理由是「no shell ever read it」。这句是**假的**,而且与本仓自己的记录直接 + 矛盾 —— 2026-06 的 AppSchema liveness 审计 + (`docs/audits/2026-06-appschema-property-liveness.md`)把 `homePageId` 明确列在 LIVE + 一侧,因为 objectui console 的 `resolveLandingRoute()` + (`packages/app-shell/src/console/AppContent.tsx`,objectui @785b8a5d)一直在读它,而且 + 它是**唯一**决定「app 打开时落在哪」的地方。两份文档矛盾了两个月无人发现,直到有人做 + cloud pin 对账时先信了这句、再去核渲染器才发现不对(#4709)。 + + 真正让这个键该走的是它的**形状**,不是无人使用:它把落地页编码成指向 `navigation` 的 + ID 交叉引用,没有引用完整性 —— id 悬空时**静默**回退到第一项(objectui 的实现正是如此), + 于是同一件事有两个来源,而错的那个不出声。将来若要「落地页 ≠ 第一项」,正确形状是导航项 + 自身的标记(`navigation[].landing`:单一来源、不可能悬空),并按 enforce-first 设计 + (先有渲染器与测试,再进 schema)。 + + 墓碑文案改为诚实版本后,作者看到的处方**保持不变**:删掉这个键;要改 app 从哪里打开就 + 重排 `navigation` 让目标项排第一;根落地由 `isDefault` 决定。同步纠正:conversion 摘要 + (经 `gen:upgrade-guide` / `gen:spec-changes` 重生成到 `docs/protocol-upgrade-guide.md` + 与 `spec-changes.json`)、生成文档 `content/docs/references/ui/app.mdx`、 + `content/docs/ui/apps.mdx`、liveness ledger 的 `homePageId` note、`examples/app-showcase` + 里那句「has no console consumer yet」,并给 6 月审计补了一条指向 #4667/#4709 的后续注记 + (审计结论本身是对的,原文不动)。新增一条 pin 测试,防止「无人读过」这类假前提回潮。 + + objectui 侧那段永远进不去的 `if (homePageId)` 死分支单独清理: + `objectstack-ai/objectui#3264`。 + +- 742cebb: refactor(spec): 双源 C11 收敛 — `HttpRequest` 类型别名改为 re-export `./shared` 的唯一声明 (#4688) + + `HttpRequest` 这个名字过去在 `@objectstack/spec/shared` 和 `@objectstack/spec/ui` 解析到**两份不同的类型声明**,是 `dual-source-exports.baseline.json` 上的一行(#4411 陷阱)。现在 `./ui` 直接 re-export `./shared` 的那一份,平台只剩一个声明。 + + 基线 **19 → 18**。 + + ## 为什么是 patch 而不是 major —— 消费者侧零类型差异,已实证 + + #4535 主单把 v17 的三个双源簇统称 breaking。**本簇不是**,原因是这一簇和其它簇形状不同: + + `HttpRequestSchema` **从来只有一份声明**(在 `shared/http.zod.ts`)。`ui/view.zod.ts` 一直是 `import` 进来再原样 re-export 的,所以基线里根本没有 `HttpRequestSchema` 行。被判为双源的只有 `ui/view.zod.ts` 底部那个**本地类型别名**: + + ```ts + // FROM —— ./ui 的本地 infer(第二个类型声明符号) + export type HttpRequest = z.infer; + + // TO —— re-export ./shared 的唯一声明 + export type { HttpRequest } from "../shared/http.zod"; + ``` + + 两者 `z.infer` 的是**同一个** schema 对象,所以解析出来的类型逐字段相同。这不是推断,是编译器验过的: + + ```ts + type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y + ? 1 + : 2 + ? true + : false; + type Assert = T; + + type PreFixUiHttpRequest = z.infer; // FROM,逐字复刻旧那行 + type _A = Assert>; // ✅ 通过 + type _B = Assert>; // ✅ 通过(FROM === TO) + type _NEG = Assert>; // ❌ TS2344,证明上面两条不是空转 + ``` + + 配套证据:`api-surface.json` 零改动(名字、入口、kind 全部不变),`authorable-surface.json` 零改动,无 tombstone,无 ADR-0087 conversion —— 因为没有任何可作者化的 key 或运行时行为发生变化。 + + **所以升级者无需做任何事。** 没有 FROM → TO 迁移动作,`import type { HttpRequest } from '@objectstack/spec/ui'` 和 `from '@objectstack/spec/shared'` 都照旧可用,且现在保证指向同一个声明。谎报破坏和漏报破坏一样会污染升级指南,故按实际情况定级为 patch。 + + ## 回归 pin + + `src/ui/view.test.ts` 新增三条**运行时**断言(#4642 已证本包的编译期 pin 空转:`tsconfig.json` 排除 `**/*.test.ts`,vitest 也不开 `typecheck`)。其中第三条用 TypeScript compiler API 在 `src/` 上做符号身份解析 —— 因为 `HttpRequest` 是**类型**,运行时看不见它,而这恰恰是本簇唯一改动的东西。三条已 sabotage 验证会红: + + - 还原旧的本地 infer 别名 → `expected 'src/ui/view.zod.ts:2056' to be 'src/shared/http.zod.ts:54'` + - 删掉 re-export 不补 → `` ./ui must still export the name `HttpRequest` `` + - 在 `./ui` 重新声明第二份 `HttpRequestSchema` → 运行时身份断言失败 + + ## 未纳入:紧邻的 `HttpMethod` + + `ui/view.zod.ts` 下一行的 `export type HttpMethod = z.infer< typeof HttpMethodSchema >` 是**完全相同的形状**,基线行 `HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]` 仍在。#4535 已把它排进 v18,范围由维护者定,故本 PR 不动它。 + +- 0f9faa2: The liveness gate now governs every registered metadata type (#4487) + + `GOVERNED` in `check-liveness.mts` was a hand-maintained list, and nothing ever + compared it against the registry it claims to cover. It governed **15 of 25** + registered metadata types while reporting itself complete. A type in the other + ten was authorable — served by `/api/v1/meta/types/:type`, editable in Studio — + and was never asked who reads its properties, so an inert key on it was + invisible to CI and its silence read as success. + + `datasource` was in that state for its entire life. #4410, #4465 and #4481 found + six inert keys on it **by hand**, two of them security-shaped: `schemaMode` was + dropped between the record and the connection spec, so a database ObjectStack + must never run DDL against was constructed as `managed`; `ssl` stopped at the + record, so a TLS block with a CA certificate in it configured nothing while + looking identical to one that worked. + + **The gate is now answerable to the registry.** Every registered type must be in + `GOVERNED` or in `PENDING_GOVERNANCE` with a reason and an issue. Registering a + type and forgetting the ledger fails CI with the entry to write. The reverse rots + too, so it also fails: a `PENDING_GOVERNANCE` row for a type that has since been + governed claims a debt that no longer exists. + + **`datasource` is now governed** — `liveness/datasource.json`, all 43 properties + classified with evidence. The result is the highest dead ratio of any governed + type: **20 of 43 have no runtime consumer.** + + | Dead cluster | Why | + | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `capabilities.*` (11) | The engine gates pushdown on the runtime driver's own `supports.*` object — `autonumber`, `batchSchemaSync`, `queryDateGranularity` — a different mechanism whose vocabulary does not overlap this block at all. `having-filter.ts` says it outright: "SQL pushdown can come later behind a driver capability flag." | + | `healthCheck.*` (3) | Nothing schedules a datasource probe. Liveness is checked on demand through the driver handle's `ping()`. | + | `retryPolicy.*` (4) | No connect or query path retries. | + | `external.label`, `external.requirePermission` | No reader. | + + **One correction ships with this**, and it is the reason the audit was worth + doing rather than a bookkeeping exercise. `capabilities.readOnly` reads as a + safety switch and gates nothing — and **two shipped prescriptions pointed + authors at it**: the `externalSettingsUnknownKeyError` guidance in + `datasource.zod.ts` ("or `capabilities.readOnly` to describe the driver") and + the #4465 changeset's relocation table. Both now name `external.allowWrites: +false`, which is the write gate the ObjectQL engine actually checks. An author + who followed the old advice believed they had marked a datasource non-writable + and had not. The v17 release notes carried a matching false claim — that an + unregistered `capabilities` key made the engine stop pushing work down to the + driver — corrected in the same change. + + Two traps worth naming, because both nearly produced a wrong verdict here: + + - **`healthCheck` and `retryPolicy` are name collisions.** A bare grep for + either returns plenty of live readers — the plugin health monitor, `hook`, + `job` — none of which is this type. `hook.retryPolicy` even spells its delay + `backoffMs` where this declares `baseDelayMs`; the shape mismatch is the tell + that nothing reads both. + - **objectui's `DatasourcePreview` renders `pool`, `ssl`, `retryPolicy` and + `healthCheck` as panels**, and is cited as evidence for none of them. That is + the standing rule in `liveness/README.md`, and #4481 is the fresh precedent: + the only "consumer" of `readReplicas` in either repo was a preview pill. + + The CLI advisory lint picks the ledger up automatically, so `os compile` now + warns an author who sets any of the 20. That needed one line beyond the ledger — + `datasource` had to be added to `TYPE_COLLECTIONS`. Coverage grows by marking + entries `authorWarn` only _within_ a type the lint already walks; a newly + governed type needs its collection registered or its ledger warns nobody. + + Nine types remain ungoverned and are now enumerated rather than implied: + `app`, `book`, `doc`, `email_template`, `job`, `mapping`, `seed`, `translation`, + `validation` (#4488). + +- 155507e: fix(spec): register the missing ADR-0087 migration surface for the `enable.trash` / `enable.mru` removal, and repoint its tombstones at the parked soft-delete issue (#3207) + + The 16.x removal of the dead object capability flags (PR #3414, + `remove-enable-trash-mru`) tombstoned both keys in the `.strict()` + capabilities block but registered no D2 conversion. Two consequences this + closes: + + - **Stored 16.x rows flagged forever.** A `sys_metadata` object row written + before the removal still carries `enable.trash`/`enable.mru`; with no + conversion to own that history, every rehydration re-flagged it + `metadata_spec_invalid` — mislabelling chain-owned history as a + current-contract violation (#3903's invariant). The new + `object-enable-trash-mru-removed` conversion (protocol 17, + `retiredFromLoadPath`) strips both keys on the stored-row pass, and + `os migrate meta --from 16` now rewrites authored sources. + - **Tombstones pointed at a closed issue.** The prescriptions named #1893 + (closed 2026-07-24) as where a real recycle bin returns. Per the #3207 + ruling (2026-08-02), soft delete is parked at #3146 — the `trash` + tombstone, the `restore` legacy-apiMethod guidance and the api-derivation + note now point there, and both tombstones name the + `os migrate meta --from 16` rewrite. + + FROM → TO: `enable.trash` / `enable.mru` → _(removed)_ — delete the key; + the flags never gated behavior, so the rewrite is lossless. + +- 061406d: The protection-envelope invariant test was hollow — it silently skipped 24 of 25 registered types. Fixed, and it immediately found 8 undeclared envelopes instead of 1. + + The check shipped in the previous change asserted two things about every registered metadata type: that it does not _reject_ the ADR-0010 envelope its loader stamps (the hard-422 case), and that it does not _strip_ it (the silent-loss case). The reject half worked — it found `hook` and `datasource` on its first run. + + The strip half did not. It probed each schema with one generic body and asked whether `_packageId` survived; a type whose required fields that body did not satisfy failed for unrelated reasons and the assertion returned early. **24 of the 25 types took that early return.** Only `field` was ever actually checked, and the suite reported green. + + That is the campaign's own subject matter — a success signal covering an omission — reproduced inside the instrument built to detect it, one change after the ledger recorded the same lesson about the strictness gate's non-recursive directory walk. A check that skips is indistinguishable from a check that passes. + + **The declaration side is now structural.** It walks the schema — unwrapping `lazy` / `pipe` / `optional` / `default` and expanding unions — and asks whether any resolved object shape declares the key. That answer does not require constructing a valid instance, so it cannot skip. Two guards keep it honest: a type whose shape the walker cannot resolve is a hard failure (the walker going quiet is exactly when this test would otherwise stop covering something), and the debt list carries a reverse pin that fails when an entry is fixed, so the list cannot outlive the debt it tracks. + + **What it found:** 8 registered types do not declare the envelope, not 1 — `action`, `book`, `field`, `job`, `mapping`, `page`, `translation`, `validation`. `job` and `book` are closed here, leaving 6 on the list. Each is protection metadata lost on every round-trip today, and a hard 422 the day its schema is closed. + +- 9c93465: `check:react-conformance` → `check:react-declaration-parity` — the gate compares two declarations, and said it compared a declaration to an implementation. + + Its header opened by claiming it "confirms the objectui components **ACTUALLY implement** + the props the spec protocol declares". It never could. Both sides of its diff are + declarations: the spec zod schema's props on the left, and on the right the `inputs` the + objectui _registry config_ declares — copied verbatim into `sdui.manifest.json` by + `manifestFromConfigs`. No renderer appears anywhere in the chain. So a prop **both sides + declare and nothing reads** is, to this gate, perfect agreement. + + That is not hypothetical. #4413's four blocks (`record:details` / `record:highlights` / + `record:related_list` / `record:path`) published `objectName`/`recordId` that no renderer + read, rendered a "bind a record to preview" placeholder on a `kind:'react'` page, and sat + behind `{ "frontendOnly": [], "missing": false }` in the committed baseline for the whole + life of the defect. A human reading the objectui renderers found it. A gate reporting + green on a promise it cannot keep is worse than no gate — without one, someone checks by + hand. + + Prime Directive #10 (declared ≠ enforced), landing on the thing whose job is to catch it. + Same shape as #1475's "spec declares 9 validation rules, the executor honors 3". + + - **Renamed to what it does**, name and header together, because the name was load-bearing + in the misreading: `check-react-blocks-declaration-parity.ts`, + `react-declaration-parity.baseline.json`, and `frontendOnly` → `registryOnly` in the + baseline ("the registry _declared_ it", not "the frontend _implements_ it"). + - **The scope caveat is emitted on every run, clean ones included.** Whoever forms a + belief about this gate is reading a CI log, not a source header. + - **It actually gates now.** `gen-sdui-manifest.sh` ran it without `--strict` and swallowed + the exit code behind a `⚠`, so even the divergence it _could_ see was recorded and never + stopped (#4472 secondary finding 1). The ratchet fires only on divergence new since the + accepted baseline, so a failure is always a deliberate registry change. + - **The claim is pinned by a test.** `check-react-blocks-declaration-parity.test.ts` + asserts both directions of what the gate can see, that the caveat rides along, and that + the implementation claim does not come back. + + What it sees is unchanged and still worth having — `spec-only` (palette gap, soft), + `registry-only` (undocumented extension, ratcheted), `missing` (not registered / not + public). Exactly one class is invisible: both sides declare it, nothing reads it. + + Evidence about the render path has to come from the render path, which is objectui's side. + `public-block-binding-reach.test.tsx` there mounts every public block declaring an + `objectName` under a recording `dataSource` and asserts the binding reaches it; its first + run separated five bound blocks from three unbound and surfaced two real defects of the + same shape (objectui#3144) — the confirmation this evidence was never obtainable here. + ADR-0082 carries the addendum; the 2026-06 audit carries a correction banner over the + assumption that carried the mistake ("the component reads its full config from the spec + schema at render" — an expectation, never measured). + +- 63b33e6: A stored reference value that is an embedded record is no longer a valid id + (#4455). + + `os migrate value-shapes` is the evidence half of the ADR-0104 D1 per-deployment + gate, and its own header names the case it exists for: "a `location` stored as + `{latitude, longitude}` **or a `lookup` holding an expanded record object**". The + second case was not detected. `ReferenceIdValueSchema` was + `z.string().min(1)`, and in a SQL deployment a legacy embedded reference reaches + storage as JSON _text_ in a TEXT column — a non-empty string. So a deployment + carrying exactly the values the gate exists to find ran the scan, was told it was + clean, and closed the gate with `--apply`; because the scan deliberately imports + the write-path predicate, the write path was equally blind and the value survived + future writes too. + + `ReferenceIdValueSchema` now rejects a value whose first non-space character is + `{` or `[`, in both the stored and the expanded form (`$expand` produces an + object, never its serialization). + + The rejection is deliberately narrower than the issue's first suggestion. Its + file sibling `FileReferenceIdValueSchema` can bound its charset because a + `sys_file` id is minted by the platform and by nothing else; a reference id is + whatever the target object's primary key holds, including an external key an + ADR-0015 federated datasource supplies. So this rejects the shape that is + provably not an id (`{"id":"acc_1","name":"embedded"}`) and leaves the id + alphabet to the object that owns it — `CB0-2026-0001`, `SFDC:001xx…` and + `ops/eu-west/tenant-7` all remain valid. Widening it further needs evidence about + real external keys, not a guess. + + Reaches authors through the ADR-0104 warn-first path (a `[value-shape]` log line) + until a deployment opts into strict, so nothing starts rejecting writes on + upgrade — but the scan now counts these values, and a deployment holding them can + no longer close the gate. + +- 0c0fbd9: fix(spec): strip the `Schema` suffix by anchored regex when deriving published JSON Schema names (#4592) + + `build-schemas.ts` / `build-docs.ts` turned an exported const name into its + published schema name with `key.replace('Schema', '')` — a **string** pattern, + which replaces the FIRST occurrence. Every const whose name also contains + `Schema` in prefix/middle position lost that inner segment instead of its + suffix, so four schemas were published — `$id` URL, `json-schema.manifest.json` + key, docs page section, and import example — under type names that exist + nowhere in the export surface. Both generators now share one anchored helper + (`schemaNameFromExportKey`, `key.replace(/Schema$/, '')`). + + Corrected names, FROM → TO (the fix if you referenced an old `$id` under + `https://schema.objectstack.io/v17/...` is to swap in the new name — the TS + exports themselves never changed): + + | exported const | old (wrong) schema name | new schema name | + | :----------------------------------- | :------------------------------------ | :------------------------------------ | + | `SchemaModeSchema` | `data/ModeSchema` | `data/SchemaMode` | + | `SchemaChangeSchema` | `system/ChangeSchema` | `system/SchemaChange` | + | `SchemaLevelIsolationStrategySchema` | `system/LevelIsolationStrategySchema` | `system/SchemaLevelIsolationStrategy` | + | `DocumentSchemaValidationSchema` | `data/DocumentValidationSchema` | `data/DocumentSchemaValidation` | + + The four old manifest keys are removed as a deliberate retirement per the + #2978 rule (they never named a real exported type), and the four + `no schema const export` / `no type export` pairs they caused in + `docs-import-surface.baseline.json` are deleted (152 → 144 accepted gaps) — + the four reference-doc pages regain real, compilable import examples. + +- 5a84d41: fix(automation): `resume` enforces the suspended screen's declared field contract (#4477) + + A `screen` node's `config.fields` is a complete input contract — the author + declares the keys, their `required`-ness, and (via `visibleWhen`) when a field + is even asked for. The RENDER half honoured all of it: the paused result and + `GET …/runs/:runId/screen` carry `required` and `visibleWhen` intact. There was + no VALIDATION half — `POST …/runs/:runId/resume` folded whatever bag it was + handed straight into the flow variables, so a caller that skipped the dialog and + posted here directly was unconstrained by every `required` the author wrote. + Missing required fields, and keys the screen never declared, all completed the + run with `success: true`. + + Screen flows are the one place where the declared field contract is the ONLY + contract — no object schema sits behind a screen node to catch a bad bag + downstream. The platform already enforces the analogous contract everywhere else + this seam appears: action params (ADR-0104 D2), record writes (ADR-0113), + approval `decisionOutputs` (#3447). This is that rule for screen resume, built in + the same shape. + + `resume` now refuses a non-conforming submission with the new + `AutomationResult.code` `'INVALID_SCREEN_INPUT'` (a transport maps it to **400**, + as the automation domain route now does) and an `Invalid screen input: …` message + that names each violation and lists the declared field names. The refusal happens + BEFORE the suspension is consumed, so the pause stays live and the legitimate + submission still lands. + + `visibleWhen` is evaluated against the SUBMITTED values first (layered over the + run's variable snapshot), so a hidden field's `required` never fires — enforcing + it would dead-end the run at a field the user was never shown, which is #3528 + reproduced server-side. A predicate that cannot be evaluated is logged and + treated as hidden rather than visible: the client decides what the user saw, and + a broken predicate is not evidence a field was on screen. + + Scope, deliberately narrow — three shapes keep the historical pass-through: + + - an **object-form** screen (`kind: 'object-form'`), whose `fields` is empty by + construction because the client renders the object's own form and the write + path enforces that object's `required` fields itself; + - a **message-only** screen (`waitForInput: true`, no fields), which declares no + keys and so constrains none — the same pass-through `enforceActionParams` + gives a param-less action; + - `signal.output`, the node-OUTPUT namespace, which belongs to the approval-style + resume envelope rather than to the screen's collected-values channel. + +- e336549: build: 为生成物加 `merge=os-regen` 合并驱动,把「集合运算被打成文本冲突」的返工消掉 (#4675) + + `packages/spec` 的生成物是排序数组与追加式登记表。两个 PR 各增删几行,语义上是集合并与集合差、完全可组合,git 却按三路文本合并报成需要人工解决的冲突 —— 2026-08-02 一个下午实测四次合并、九处冲突,**没有一次是真正的语义冲突**,每次的正确解法都是「丢掉两边、重新生成、重跑门禁」。 + + `.gitattributes` 现在把这些路径交给 `scripts/git-merge-regen.mjs`。 + + **驱动不做重算。** git 是在合并**过程中**按索引顺序调用 merge driver 的,那一刻工作区里还是合并前的源码:`packages/spec/spec-changes.json` 排在 `packages/spec/src/...` 之前,所以在驱动里跑生成器会读到缺了对方那半边改动的 `migrations/registry.ts`,写出一个自信而错误的产物 —— 比它取代的那个冲突更糟,因为冲突标记是可见的错误,而看起来合理的生成文件不是。改为**推迟**:驱动解析路径(不做文本合并、不留标记)并记入 `$GIT_DIR/os-regen-pending`,`pre-commit` 在产物重新生成之前拒绝提交。重算因此发生在合并后的完整树上 —— 唯一正确的时刻。 + + `check:generated --fix` 现在在 `dist` 比 `src` 旧时**拒绝**运行 `gen:api-surface`,而不再只是警告。陈旧 dist 下该生成器不会失败,它会写出一份缺失了上次构建以来所有新导出的、看似合理的 surface,并让 `gen:docs` 顺手为这个缺口棘轮一条基线豁免(#4687 实际发生过,只靠与 `main` 对比生成物才发现)。`--fix` 是唯一会**写入**的路径,所以是这个陷阱唯一不可幸存的地方。 + + 只减不增的棘轮(`docs-import-surface.baseline.json`、`dual-source-exports.baseline.json`)与手写登记表刻意排除在外:重算一个只减不增的棘轮可能**放宽**它,等于把一条新豁免当作合并噪音洗进来。这些冲突仍然留给人看,逐条理由见 `scripts/regen-artifacts.mjs` 的 `NOT_DRIVER_MANAGED`。 + + 驱动按 clone 注册(`pnpm install` 经 `prepare` 完成)。没注册的 clone 回退到 git 默认文本合并 —— 即 #4675 之前的行为,不是故障。`pnpm check:merge-driver` 双向核对 `.gitattributes` 与该表,并对真实 git 做端到端验证。 + +- d40f43a: fix(spec): give this package's vitest run a 60s `testTimeout` — stop evicting unrelated PRs from the merge queue (#4850) + + `packages/spec/vitest.config.ts` never set `testTimeout`, so every case in the + package ran under vitest's **5000ms** default. Twelve tests in `src/` load the + TypeScript compiler inside the case and type-resolve the whole export surface — + `ts.createProgram` + `getTypeChecker`, then unalias each symbol and chase + `originOf` — which is seconds of work by construction, not a hang: + + ``` + api/rest-server · automation/state-machine · automation/sync-retirement · cloud/tenant + data/driver · integration/connector · kernel/package-dependency-dual-source + studio/action-location-retirement · system/environment-artifact · system/notification + ui/app · ui/view + ``` + + Measured on an idle runner the slowest of these cases takes **3.4s** against a + 5000ms budget — enough margin to stay green on a PR branch, and not enough on a + merge-queue runner building several PRs' batches at once. That is exactly the + observed signature: five failures in one night, all inside the queue, none on a + PR branch, each one evicting a PR that had nothing to do with `spec` (#4755, + #4788, #4823, #4822 twice). + + `testTimeout: 60_000` matches the value PR #4506 gave these same cases + case-by-case, but applied once at the config layer so all twelve are covered — + and so a thirteenth added later is covered on arrival instead of leaking through + the way the per-case list did. 60s is ~17x the slowest measured case, so it + absorbs queue contention without masking a genuine hang. + + This is a **stop-the-bleeding** change, not a fix for the underlying cost: 88% of + those twelve files' test time is TypeScript compilation, ~39s of it, repeated per + run. Hoisting the export-surface resolution into a build-time artifact is tracked + separately in #4796, which stays open. + + No runtime, schema or public API change — test configuration only. + +- 04f1182: docs(spec): SystemFieldName says which columns are actually injected (#4430) + + `SystemFieldName` presents itself as the canonical protocol-level names for + system fields, but it was neither the injected set nor a complete one — and it + had the most load-bearing entry backwards. `TENANT_ID` was documented as + "Tenant isolation key" while the column the registry actually provisions is + `organization_id`, which had no constant at all. Nor did `created_by` / + `updated_by`, the other half of the audit-provenance family. Two of the seven + entries (`user_id`, `deleted_at`) are not injected either, with nothing in the + table saying so. + + Consumers hand-copying a system-field list read the table as the injection set + and drifted accordingly. cloud#982 found three such copies in one package + carrying `tenant_id`, `org_id` and `space` between them — three spellings no + injection site produces — and cloud#979 was one of those copies claiming a + business field named `owner`, so every seeded row of a user's app shipped its + 负责人 column blank. + + **Additive only. No entry removed, no value changed**, so existing + `SystemFieldName.X` references are unaffected. + + - Adds `ORGANIZATION_ID`, `CREATED_BY` and `UPDATED_BY` — the three injected + columns the table was missing. + - Records per entry whether open-core actually injects it, so the legacy + (`tenant_id`, stamped from the session's _organization_ id only on an object + that declares it) and authored (`user_id`) names can no longer be mistaken + for provisioned ones. + - States in the module doc that this is a NAME registry, not the injected set. + `applySystemFields` decides that per object from `ownership` / `tenancy` / + `systemFields`, so the same name is a system column on one object and + business data on the next. A consumer asking "is this field system-managed on + THIS object" branches on `Field.system` — already published for exactly that + purpose, and now pointed at from here. + + `@objectstack/lint`'s `SYSTEM_FIELDS` is unchanged in content: it unions this + table with `FIELD_GROUP_SYSTEM_FIELDS`, which already carried all three added + names. + ## 17.0.0-rc.1 ### Major Changes @@ -1471,8 +6096,8 @@ schedule }`), not on the flow. 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 + 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. diff --git a/packages/spec/package.json b/packages/spec/package.json index 282f29ed32..d2c2f56fb2 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 eb6cdcc225..12b8857845 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/trigger-api +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 25ddcf2ccb..c4fda9d7d3 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.1", + "version": "17.0.0-rc.2", "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 03895cf920..9f3a9de060 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/plugin-trigger-record-change +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index f85a993d84..e95ab110c8 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.1", + "version": "17.0.0-rc.2", "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 069b9d7c64..c1d5bdc67d 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,117 @@ # @objectstack/plugin-trigger-schedule +## 17.0.0-rc.2 + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 5179483ed2..f4893f5d5f 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.1", + "version": "17.0.0-rc.2", "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 3a4c7dbc8c..a4d6127540 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,160 @@ # @objectstack/types +## 17.0.0-rc.2 + +### Minor Changes + +- b25a116: fix(verify): resolve the enterprise organizations package from the HOST APP (#4700) + + `bootStack(app, { multiTenant: true })` — and therefore `objectstack verify +--multi-tenant` — could never load `@objectstack/organizations`. Node ESM + resolves a bare `import()` against the **importer's own realpath**, which for + `packages/verify` is inside the framework workspace, while the enterprise + package is cloud-private and only ever lives in the verified app's + `node_modules`. Every real host app fell into the catch and was told to + "Install/link it in this workspace" — about a package it had already installed. + Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed + that one call site and this issue tracked the two the sweep left behind. + + **New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` / + `createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts` + — where `@objectstack/verify` and the dogfood suite could not import it without + inverting the dependency direction — into a **node-only subpath export** of + `@objectstack/types`. One behaviour, one source; the CLI now consumes it and its + private copy is deleted. + + It is a subpath and **not** the root export because `@objectstack/types` is a + dependency of `@objectstack/hono` ("edge-compatible REST API server for + Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel` + boots on Workers. The root entry reaches zero `node:` builtins, and a Workers + bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two + entries as separate self-contained bundles (`splitting: false`), and a test + walks the root's import graph and fails on the first reachable `node:` + specifier, so the isolation is enforced rather than merely intended. Same + arrangement `@objectstack/metadata` already ships for its `./node` subpath. + + **New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names + the app whose `node_modules` supplies those optional packages — for a harness + booting an app that is not the working directory. + + **The dogfood multi-org gates had never run.** Two suites probed availability + with the same bare `import()` and so were **constant-false** — not "false + because absent" but false by construction, in every environment including the + cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and + the attachments cross-tenant isolation block had therefore never executed while + the suite reported green (Prime Directive #10, test-suite edition). They now + resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a + run is expected to ship the package — turning a silent skip into a loud failure, + so a run can no longer pass by quietly not running the gates it exists for. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [e6b1b69] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [203a449] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [ec975f1] +- Updated dependencies [eb4204b] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [04f1182] +- Updated dependencies [5647006] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/spec@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 8eb712fde7..2e2b603201 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 2877d25465..f93ae49c20 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,247 @@ # @objectstack/verify +## 17.0.0-rc.2 + +### Minor Changes + +- b25a116: fix(verify): resolve the enterprise organizations package from the HOST APP (#4700) + + `bootStack(app, { multiTenant: true })` — and therefore `objectstack verify +--multi-tenant` — could never load `@objectstack/organizations`. Node ESM + resolves a bare `import()` against the **importer's own realpath**, which for + `packages/verify` is inside the framework workspace, while the enterprise + package is cloud-private and only ever lives in the verified app's + `node_modules`. Every real host app fell into the catch and was told to + "Install/link it in this workspace" — about a package it had already installed. + Same defect class as cloud#1013, which fixed `objectstack serve`; #4699 fixed + that one call site and this issue tracked the two the sweep left behind. + + **New: `@objectstack/types/node`.** The host-app resolver (`createHostRequire` / + `createHostImporter`) moved out of `packages/cli/src/utils/import-from-host.ts` + — where `@objectstack/verify` and the dogfood suite could not import it without + inverting the dependency direction — into a **node-only subpath export** of + `@objectstack/types`. One behaviour, one source; the CLI now consumes it and its + private copy is deleted. + + It is a subpath and **not** the root export because `@objectstack/types` is a + dependency of `@objectstack/hono` ("edge-compatible REST API server for + Cloudflare Workers, Deno, Bun, and Node") and of the plugin layer a `LiteKernel` + boots on Workers. The root entry reaches zero `node:` builtins, and a Workers + bundle breaks on `node:module` even when nothing calls it. `tsup` emits the two + entries as separate self-contained bundles (`splitting: false`), and a test + walks the root's import graph and fails on the first reachable `node:` + specifier, so the isolation is enforced rather than merely intended. Same + arrangement `@objectstack/metadata` already ships for its `./node` subpath. + + **New: `BootOptions.hostRoot`** (optional, defaults to `process.cwd()`) names + the app whose `node_modules` supplies those optional packages — for a harness + booting an app that is not the working directory. + + **The dogfood multi-org gates had never run.** Two suites probed availability + with the same bare `import()` and so were **constant-false** — not "false + because absent" but false by construction, in every environment including the + cloud CI whose comment claimed it ran them. The #1994 cross-tenant RLS proof and + the attachments cross-tenant isolation block had therefore never executed while + the suite reported green (Prime Directive #10, test-suite edition). They now + resolve like the runtime does, and `OS_TEST_MULTI_ORG_ENABLED=1` declares that a + run is expected to ship the package — turning a silent skip into a loud failure, + so a run can no longer pass by quietly not running the gates it exists for. + +- c2a1134: fix(verify): stop the harness pinning `suspendedRunStore: 'memory'` (#4470) + + `bootStack` hardcoded `suspendedRunStore: 'memory'` when it registered + `@objectstack/service-automation`. That made the DB-backed suspended-run store + **structurally unreachable** from every dogfood/e2e fixture — not under-tested, + untestable. The coverage map had a clean seam nothing crossed: + + - unit tests covered ENGINE-side persistence (`suspended-run-store.test.ts` + drives suspend → restart → resume against a fake table); + - e2e covered the BUSINESS chain (approvals), but single-process and wholly in + memory; + - the ASSEMBLY between them — is `sys_automation_run` registered, is its table + created, is the store actually attached to the engine — was covered by + neither. + + #4420 grew in precisely that seam: the store hung off a table that was never + created, every write failed into a `warn` nobody read, the pause reported + success, and the run died at the next restart. #4460 added assembly unit tests; + this makes the e2e half possible. + + The harness now boots the plugin's own `'auto'` default — the same wiring + `objectstack dev` / `serve` get — so fixtures exercise the real assembly. Two + new knobs: + + - `automation` accepts `{ suspendedRunStore: 'auto' | 'memory' }` as well as + `true`, so a fixture that wants the old in-memory behaviour asks for it + explicitly rather than getting it by default. + - `databaseFile` backs the in-process SQLite database with a file instead of + `:memory:`, so state can outlive a kernel. + + Answering the question the issue raised — was `'memory'` pinned for speed or + because persistence could not run there? **Speed/simplicity.** The durable path + works in this harness: the accompanying dogfood proof boots with it, and the + whole existing dogfood suite passes on it unchanged (38 files, 239 tests). Note + `databaseFile` does not yet deliver a true cold boot: a second `bootStack` over + the same file reads a database whose tables exist but whose rows are gone — + ordinary records do not survive it either, so it is a harness/driver persistence + gap rather than anything to do with suspended runs, and it is filed as #4518. + +### Patch Changes + +- Updated dependencies [430dcc2] +- Updated dependencies [e6ac4bd] +- Updated dependencies [80334c7] +- Updated dependencies [ce5242c] +- Updated dependencies [a7163ea] +- Updated dependencies [e6e9379] +- Updated dependencies [257d97a] +- Updated dependencies [98877c9] +- Updated dependencies [98877c9] +- Updated dependencies [c44dd5e] +- Updated dependencies [e6b1b69] +- Updated dependencies [7e7a605] +- Updated dependencies [2f05139] +- Updated dependencies [fa94b2c] +- Updated dependencies [328ccc5] +- Updated dependencies [ad047d2] +- Updated dependencies [2826d1e] +- Updated dependencies [5a84d41] +- Updated dependencies [20b1a9e] +- Updated dependencies [f2eb850] +- Updated dependencies [8bd437f] +- Updated dependencies [5046afe] +- Updated dependencies [203a449] +- Updated dependencies [6dcbbc3] +- Updated dependencies [ac37fc6] +- Updated dependencies [4820f55] +- Updated dependencies [462d9c4] +- Updated dependencies [7d21581] +- Updated dependencies [f2445c9] +- Updated dependencies [23338c3] +- Updated dependencies [5b843fb] +- Updated dependencies [b4487aa] +- Updated dependencies [65ca83a] +- Updated dependencies [67bf2e2] +- Updated dependencies [c6d1cb4] +- Updated dependencies [462b713] +- Updated dependencies [36030ff] +- Updated dependencies [6117f7b] +- Updated dependencies [e533b0b] +- Updated dependencies [cdf4d9a] +- Updated dependencies [aee1806] +- Updated dependencies [c13350b] +- Updated dependencies [c13350b] +- Updated dependencies [63b33e6] +- Updated dependencies [9ca2d85] +- Updated dependencies [c13350b] +- Updated dependencies [891d345] +- Updated dependencies [a52e2ef] +- Updated dependencies [5293114] +- Updated dependencies [ff17642] +- Updated dependencies [20bc357] +- Updated dependencies [5966c2a] +- Updated dependencies [2382580] +- Updated dependencies [d9fa683] +- Updated dependencies [3c7bcc0] +- Updated dependencies [4b6cac7] +- Updated dependencies [7631964] +- Updated dependencies [4c45be1] +- Updated dependencies [ac471a0] +- Updated dependencies [60ae58e] +- Updated dependencies [ce92674] +- Updated dependencies [9f601e8] +- Updated dependencies [51c5227] +- Updated dependencies [a4a85c8] +- Updated dependencies [07a4e26] +- Updated dependencies [05d8a54] +- Updated dependencies [ec975f1] +- Updated dependencies [68c02c2] +- Updated dependencies [eb4204b] +- Updated dependencies [25784cf] +- Updated dependencies [4f13be2] +- Updated dependencies [61cc079] +- Updated dependencies [0e96e46] +- Updated dependencies [cb5a75e] +- Updated dependencies [84b6e58] +- Updated dependencies [f160ba4] +- Updated dependencies [b25a116] +- Updated dependencies [d52d4fe] +- Updated dependencies [742cebb] +- Updated dependencies [127f091] +- Updated dependencies [9fd9ae7] +- Updated dependencies [ce92674] +- Updated dependencies [cf2c9b7] +- Updated dependencies [0f9faa2] +- Updated dependencies [7cf42fe] +- Updated dependencies [5966c2a] +- Updated dependencies [8aacf94] +- Updated dependencies [f78dd83] +- Updated dependencies [a2cd18a] +- Updated dependencies [4638aaa] +- Updated dependencies [0222d3c] +- Updated dependencies [071d0dc] +- Updated dependencies [0a936ea] +- Updated dependencies [023c00b] +- Updated dependencies [155507e] +- Updated dependencies [7bba90b] +- Updated dependencies [7e05d8e] +- Updated dependencies [0d9a779] +- Updated dependencies [061406d] +- Updated dependencies [c1f344b] +- Updated dependencies [9c93465] +- Updated dependencies [ebb209c] +- Updated dependencies [63b33e6] +- Updated dependencies [2a44c1d] +- Updated dependencies [695cfbd] +- Updated dependencies [7445149] +- Updated dependencies [071d0dc] +- Updated dependencies [0848bea] +- Updated dependencies [b8b3c64] +- Updated dependencies [1ee48bc] +- Updated dependencies [0c0fbd9] +- Updated dependencies [f3141d8] +- Updated dependencies [5a84d41] +- Updated dependencies [fd3013a] +- Updated dependencies [21676eb] +- Updated dependencies [ba5ff2f] +- Updated dependencies [e336549] +- Updated dependencies [d40f43a] +- Updated dependencies [304423e] +- Updated dependencies [e5e7ee0] +- Updated dependencies [800bdb0] +- Updated dependencies [26bb053] +- Updated dependencies [be90dea] +- Updated dependencies [04f1182] +- Updated dependencies [c03108c] +- Updated dependencies [5647006] +- Updated dependencies [50185a8] +- Updated dependencies [d6bd5a1] +- Updated dependencies [38f7e4f] +- Updated dependencies [c57f3cf] +- Updated dependencies [97faca3] +- Updated dependencies [ad5fe25] +- Updated dependencies [ea90179] +- Updated dependencies [ce92674] +- Updated dependencies [5ef0b5b] +- Updated dependencies [48fbacb] +- Updated dependencies [355e951] +- Updated dependencies [dadb43f] + - @objectstack/runtime@17.0.0-rc.2 + - @objectstack/spec@17.0.0-rc.2 + - @objectstack/objectql@17.0.0-rc.2 + - @objectstack/plugin-auth@17.0.0-rc.2 + - @objectstack/plugin-security@17.0.0-rc.2 + - @objectstack/platform-objects@17.0.0-rc.2 + - @objectstack/core@17.0.0-rc.2 + - @objectstack/service-analytics@17.0.0-rc.2 + - @objectstack/service-automation@17.0.0-rc.2 + - @objectstack/rest@17.0.0-rc.2 + - @objectstack/service-datasource@17.0.0-rc.2 + - @objectstack/plugin-sharing@17.0.0-rc.2 + - @objectstack/types@17.0.0-rc.2 + - @objectstack/service-settings@17.0.0-rc.2 + - @objectstack/plugin-hono-server@17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 7e0b29cc86..4e37d9649c 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.0.0-rc.1", + "version": "17.0.0-rc.2", "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 fb0f0de3f9..6b4ccf50be 100644 --- a/packages/vscode-objectstack/CHANGELOG.md +++ b/packages/vscode-objectstack/CHANGELOG.md @@ -1,5 +1,7 @@ # objectstack-vscode +## 17.0.0-rc.2 + ## 17.0.0-rc.1 ### Patch Changes diff --git a/packages/vscode-objectstack/package.json b/packages/vscode-objectstack/package.json index cdae7a02ac..074133e3ef 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.1", + "version": "17.0.0-rc.2", "publisher": "objectstack", "license": "Apache-2.0", "repository": {